From 27fc6fb5071b9159d49aaa6cfdd98046bc9dd760 Mon Sep 17 00:00:00 2001 From: gramps Date: Sun, 1 Nov 2026 07:11:49 -0800 Subject: [PATCH] =?UTF-8?q?feat(profile):=20the=20free=20budget=20is=20pix?= =?UTF-8?q?els=20and=20fps,=20enforced=20once=20=E2=80=94=20with=20a=20dev?= =?UTF-8?q?-only=20release=20hatch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The paid delta is output quality, so the rule needs to be measurable rather than a word: 921,600 pixels and 30 fps, fail-closed. Models/OutputProfile.cs is that rule as pure policy — a permitted tier passes through untouched (Pro, or the free tier's own 720p30), a disallowed one resolves to the best tier the budget allows and carries ITS bitrate, so a capped user is never offered an 8 Mbps budget for a 720p frame. Applied once, in BuildEncoderOptions. That is genuinely one place: the stream and the local recording are two output blocks of ONE ffmpeg process fed by one EncoderOptions, so there is no second encoder to forget. (commercial.md claimed "both encoders — never two checks"; the code is stronger than the doc, and the doc is corrected.) Developer override, as asked: a "Developer mode — all resolutions" checkbox in App Settings, bound to MainViewModel.DeveloperMode, which feeds HasProOutput. It is not hidden in a release build — Controls/DeveloperPanel's Page and Compile items are removed when Configuration != Debug, and OverlayHost adds it at runtime inside #if DEBUG, so a shipped exe has no BAML for it at all. The setter also refuses the value when DevBuild.IsDevelopment is false, which is now the single source for that question (the scoreboard partial had its own duplicate const). Two traps worth their lines: a `const bool` dev flag makes `if (!Flag) return;` an unreachable-code warning in one configuration or the other (CS0162), so DevBuild uses static readonly; and a Condition on an ItemGroup is invisible to a test reading item.Attribute("Condition") — read the parent. Tests: 14 pass across OutputProfileTests, AssemblyIdentityTests and StorePackagingTests. The invariant test is the one that matters: iterate every shipped tier under a free entitlement and assert none emits more than the budget, so a future 4K tier fails here instead of shipping to everyone. App builds Debug and Release at 0 warnings. --- Controls/DeveloperPanel.xaml | 21 ++++ Controls/DeveloperPanel.xaml.cs | 21 ++++ Controls/OverlayHost.xaml | 5 + Controls/OverlayHost.xaml.cs | 6 + HANDOFF.md | 38 +++--- Helpers/DevBuild.cs | 29 +++++ Models/OutputProfile.cs | 62 ++++++++++ MyMistakes.md | 14 +++ TASKS.md | 2 +- ViewModels/MainViewModel.OutputProfile.cs | 48 ++++++++ ViewModels/MainViewModel.Scoreboard.cs | 11 +- .../MainViewModel.Streaming.Operations.cs | 6 +- docs/ai/commercial.md | 10 +- docs/ai/ui-surface.md | 1 + ytLive.Tests/OutputProfileTests.cs | 112 ++++++++++++++++++ ytLive.csproj | 8 ++ 16 files changed, 363 insertions(+), 31 deletions(-) create mode 100644 Controls/DeveloperPanel.xaml create mode 100644 Controls/DeveloperPanel.xaml.cs create mode 100644 Helpers/DevBuild.cs create mode 100644 Models/OutputProfile.cs create mode 100644 ViewModels/MainViewModel.OutputProfile.cs create mode 100644 ytLive.Tests/OutputProfileTests.cs diff --git a/Controls/DeveloperPanel.xaml b/Controls/DeveloperPanel.xaml new file mode 100644 index 0000000..64774bf --- /dev/null +++ b/Controls/DeveloperPanel.xaml @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/Controls/DeveloperPanel.xaml.cs b/Controls/DeveloperPanel.xaml.cs new file mode 100644 index 0000000..4f98c64 --- /dev/null +++ b/Controls/DeveloperPanel.xaml.cs @@ -0,0 +1,21 @@ +using System.Windows.Controls; + +namespace ytLive.Controls; + +/// +/// The developer affordances that must never exist in a shipped build: today, the +/// all-resolutions override for the output profile. +/// +/// This file is excluded from the Release compile (ytLive.csproj removes +/// the Page and this Compile item when Configuration != Debug), so a Release binary +/// contains no BAML for it and cannot reveal it. That is deliberate over the cheaper +/// "collapse the panel" approach: a collapsed panel still exists in the visual tree of every +/// shipped copy, and "not visible" is a much weaker claim than "not compiled". +/// +public partial class DeveloperPanel : UserControl +{ + public DeveloperPanel() + { + InitializeComponent(); + } +} diff --git a/Controls/OverlayHost.xaml b/Controls/OverlayHost.xaml index 62c4e73..012e1cf 100644 --- a/Controls/OverlayHost.xaml +++ b/Controls/OverlayHost.xaml @@ -111,6 +111,11 @@ + + + diff --git a/Controls/OverlayHost.xaml.cs b/Controls/OverlayHost.xaml.cs index 6752088..2be1a65 100644 --- a/Controls/OverlayHost.xaml.cs +++ b/Controls/OverlayHost.xaml.cs @@ -9,6 +9,12 @@ public partial class OverlayHost : UserControl public OverlayHost() { InitializeComponent(); +#if DEBUG + // Development affordances are added at runtime, never declared in XAML: the panel is + // excluded from the Release compile (ytLive.csproj), so this line is the only + // reference to it and a Release build has none. + DeveloperPanelHost.Children.Add(new DeveloperPanel()); +#endif } /// Is the enlarged brand-logo preview showing? Test seam — a Popup is a separate diff --git a/HANDOFF.md b/HANDOFF.md index 19ed74b..f89d10d 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,40 +1,40 @@ -# HANDOFF — 2026-11-01 (the exe is LlamaCasty.exe; the depot folder builds and verifies) +# HANDOFF — 2026-11-01 (exe renamed, depot builds, output rule enforced) ## State -- Branch `main`, level with `origin` through `8f79b20`; this unit (the artifact rename) is local. -- **The shipped exe is `LlamaCasty.exe`.** `AssemblyName` renamed; four pack URIs fixed (the docs - said three — `KeepBroadcastDialog.xaml:5` was the fourth); manifest, MSIX script and - `StorePackagingTests` follow. Guarded by `ytLive.Tests/AssemblyIdentityTests.cs`. +- Branch `main`, level with `origin` through `8f79b20`; this unit is local. +- **The shipped exe is `LlamaCasty.exe`** (`AssemblyName` renamed; four pack URIs fixed — the docs + said three, `KeepBroadcastDialog.xaml:5` was the fourth). Guarded by `AssemblyIdentityTests`. - **`scripts/publish.sh`** stages `artifacts/steam-stage` and refuses to finish if `ffmpeg\`, the licence, privacy or notices file is missing, or test assemblies leaked in. Output 257 MB. -- **Distribution is Steam-exclusive** (creator ruling 2026-10-31): free app + paid edition as a - Steam **DLC**; the **$10/month** plan sold off-Steam by Polar and *stated, never sold*, in-app. - `commercial.md` → Channels is policy; `Distribution.md` is depot mechanics. +- **The output-profile rule is built and enforced**: `Models/OutputProfile.cs` (921,600 px / 30 fps, + fail closed) resolved once in `BuildEncoderOptions`, which feeds the one ffmpeg process writing + both outputs. `MainViewModel.DeveloperMode` bypasses it in Debug only. +- **Distribution is Steam-exclusive** (2026-10-31): free app + paid edition as a Steam **DLC**; the + **$10/month** plan sold off-Steam by Polar, *stated, never sold*, in-app. - **Entitlement is `steamDlcOwned || polarKeyValid`**; `steam_api64.dll` ships beside the exe and the app must start without Steam, falling back to free. -- **Site is live with Steam** (`a9d07ce`). `www.llamacasty.com` 301s to the apex. +- **Site live with Steam** (`a9d07ce`); `www.llamacasty.com` 301s to the apex. ## Next action -1. **OutputProfile gate** — ≤921,600 px / ≤30 fps for the stream **and** the recorder, greyed tier - entries, plus the TASK 53 geometry tests that pin every tier. -2. Then the **entitlement seam** (`IEntitlement`, Steam DLC path behind a fake, Polar path) and the - licence UI. -3. Steamworks: App ID waiting on identity verification; then depot, store page (public 2 weeks - before release) and the DLC app. +1. **Greyed tier entries + entitlement wiring** — the rule is in place and tested + (`Models/OutputProfile.cs`); the dropdown still shows every tier as equally available, and + `HasProOutput` is `IsPremium || DeveloperMode` with no Steam DLC path yet. +2. Then the **entitlement seam** (`IEntitlement`: Steam DLC via `steam_api64.dll`, Polar lease). +3. Steamworks: App ID waits on identity verification; then depot, store page and the DLC app. ## Landmines - **⛔ `ExcludeFromSingleFile="true"` on the ffmpeg content** — without it the publisher bundles libav\*.dll into the exe and the app ships unable to find ffmpeg. Recipe in `MyMistakes.md`. +- **Dev affordances are compiled out, not hidden** — `DeveloperPanel` is removed from the Release + build; `Helpers/DevBuild.cs` is the single source for the flag (never `const`). - **Nothing may gate a feature.** The only paid delta is the output profile; `ShowBrandFlash` stays `false` and entitlement must never enable it. - **No checkout inside a Steam build** — the Wallet rule. The monthly is named, never sold, in-app. - **The org access token never ships.** `organization_id` is public; the token is not. - **The site publishes the model, never the numbers**, and never takes payment or links a checkout. - **No self-updater and no version banner**: Steam updates the app. -- Pre-existing: the **full test suite hangs** (WASAPI teardown) — per-class runs only; and - `ytLive.Tests` carries 3 xUnit analyzer warnings the app-only gate does not see. -- Not every hex is a theme colour — `BorderColorPalette`/chat defaults are USER DATA; on-stream - graphics come from `OutputPalette`. A red settings control is still a bug. +- Pre-existing: the **full test suite hangs** (WASAPI teardown) — per-class runs; `ytLive.Tests` + carries 3 xUnit warnings the app-only gate does not see; a red settings control is still a bug. diff --git a/Helpers/DevBuild.cs b/Helpers/DevBuild.cs new file mode 100644 index 0000000..617ba17 --- /dev/null +++ b/Helpers/DevBuild.cs @@ -0,0 +1,29 @@ +namespace ytLive.Helpers; + +/// +/// Whether this binary is a **development build**. +/// +/// Why static readonly and not const: a const makes +/// if (!DevBuild.IsDevelopment) return; a compile-time branch, so whichever arm is dead +/// raises CS0162 — in Debug the guard is dead, in Release the body is. A readonly field is set +/// once at type init, so both arms compile clean and the JIT folds the branch away. +/// +/// This is not the only defence. The developer panel's XAML and code-behind are +/// removed from the Release compile entirely (ytLive.csproj, Page/Compile Remove), so a +/// shipped binary contains no BAML to reveal. This flag is the second lock: it refuses the +/// value even if something else calls the setter. +/// +/// Single source for the question. It gates the multi-instance profile +/// (), the scoreboard's debug seeding, and the all-resolutions +/// override (MainViewModel.DeveloperMode). +/// +public static class DevBuild +{ + /// True for a Debug compile; false for Release and anything else. + public static readonly bool IsDevelopment = +#if DEBUG + true; +#else + false; +#endif +} diff --git a/Models/OutputProfile.cs b/Models/OutputProfile.cs new file mode 100644 index 0000000..31763a9 --- /dev/null +++ b/Models/OutputProfile.cs @@ -0,0 +1,62 @@ +namespace ytLive.Models; + +/// +/// The one entitlement rule, as pure policy: what a build is allowed to emit. +/// +/// Why pixels and a frame rate rather than "720p". The paid delta is output +/// quality, and a resolution name is a marketing word, not a measurable bound — 720p60 is +/// 921,600 pixels at twice the frame rate, and vertical 720×1280 is the same pixel count +/// with a different shape. A pixel budget plus a frame-rate ceiling expresses the same +/// promise without a lookup table that a new tier can quietly escape. +/// +/// Fail closed. Callers pass pro: false when entitlement is unknown, +/// unreadable or unresolved; the free profile is the default state, never the fallback +/// after an error. +/// +/// This is not a feature gate. Every feature, scene, overlay and the recording +/// itself stay available; only the emitted frame gets smaller. Never call it for anything +/// else — a cracked build buys almost nothing, which is the entire anti-piracy posture. +/// +public static class OutputProfile +{ + /// Free ceiling: 921,600 px = 1280×720, i.e. one 720p frame. + public const int FreeMaxPixels = 921_600; + + /// Free ceiling: 30 frames per second. + public const int FreeMaxFps = 30; + + /// The named tier a capped build falls back to when nothing else qualifies. + public const string FreeFallbackName = "720p30"; + + /// Is this tier within the free budget? Pro may use anything shipped. + public static bool IsAllowed(QualityOption tier, bool pro) + => pro + || (tier.Width * tier.Height <= FreeMaxPixels && tier.Fps <= FreeMaxFps); + + /// + /// The tier a session will actually encode. A permitted selection passes through + /// untouched (so Pro and the free tier's own 720p30 keep their exact bitrate); a + /// disallowed one resolves to the best tier the budget allows — most pixels first, then + /// most frames, then the cheapest bitrate — because silently encoding something the user + /// did not pick is only acceptable if it is the closest legal thing to it. + /// + /// What the dropdown says. + /// Every tier the build offers. + /// Entitlement, already resolved. Unknown must be passed as false. + public static QualityOption Resolve( + QualityOption requested, IEnumerable shipped, bool pro) + { + if (IsAllowed(requested, pro)) return requested; + + var candidate = shipped + .Where(tier => IsAllowed(tier, pro)) + .OrderByDescending(tier => tier.Width * tier.Height) + .ThenByDescending(tier => tier.Fps) + .ThenBy(tier => tier.Bitrate) + .FirstOrDefault(); + + // No shipped tier fits the budget: build the floor explicitly rather than trusting + // the table. A build whose tiers were all redefined must still emit a legal frame. + return candidate ?? new QualityOption(FreeFallbackName, FreeMaxFps, 6.0, 1280, 720); + } +} diff --git a/MyMistakes.md b/MyMistakes.md index 51f6cf1..6aff0fe 100644 --- a/MyMistakes.md +++ b/MyMistakes.md @@ -166,6 +166,20 @@ in Studio → Settings → Channel → **Feature eligibility**: *"Advanced YouTube features aren't available for this account"*. If ID verification is ever wanted on a personal channel, do it **before** converting to a brand account. +### THE `const` THAT MAKES A GUARD UNREACHABLE (2026-11-01) + +A dev-build flag declared `const bool` turns `if (!DevBuild.IsDevelopment) return;` into a +compile-time branch: Debug reports **CS0162 unreachable** on the guard, Release reports it on the +body — and the repo builds at 0 warnings, so either config fails the gate. + +**Fix:** `public static readonly bool IsDevelopment =` … set once from `#if DEBUG`. Both arms +compile clean in both configurations, and the JIT still folds the branch. The *real* guarantee for +"this must not ship" is not the flag anyway — it is removing the UI's `Page`/`Compile` items from +the Release build (``). + +⚠️ A Condition on the **ItemGroup** is invisible to a test that reads `item.Attribute("Condition")`. +Read the parent too — that mistake cost one failing test here. + ### THE SINGLE-FILE PUBLISHER EATS ffmpeg (2026-11-01) **Problem:** `dotnet publish` with `PublishSingleFile=true` + diff --git a/TASKS.md b/TASKS.md index e201ccf..1afaa52 100644 --- a/TASKS.md +++ b/TASKS.md @@ -49,7 +49,7 @@ Dog test. | 54 | 🔶 | Freemium: EULA + privacy shipped; **no licence path built** — needs the entitlement seam, the output-profile cap and the licence UI below; site copy still says free forever | [`task-54`](TASKS/task-54-marcom-free-merch.md) | | — | ☐ | **Steamworks entitlement** — `steam_api64.dll` + `SteamAPI.Init`, `BIsDlcInstalled(dlcAppId)` behind `IEntitlement`, free profile when Steam is absent | [`commercial`](docs/ai/commercial.md) | | — | ☐ | **Entitlement seam** — Polar customer-portal path for the monthly (org ID public, org token never in the app), `conditions: {channel_id}`, 7–14 day lease, `--activate` CLI. **No claim page, no server** | [`commercial`](docs/ai/commercial.md) | -| — | ☐ | **OutputProfile gate** — ≤921,600 px / ≤30 fps for stream **and** recording, greyed tier entries, live re-resolve, never mid-stream | [`commercial`](docs/ai/commercial.md) | +| — | 🔶 | **OutputProfile gate** — resolver + `EncoderOptions` wiring **done** (`Models/OutputProfile.cs`, one ffmpeg/two outputs, dev override); remaining: greyed tier entries in the dropdown, entitlement wiring | [`commercial`](docs/ai/commercial.md) | | — | ☐ | **Licence UI** — four states, 7/1/0-day notices, "monthly is on the website, not Steam" line, portal link, licence ID in About | [`commercial`](docs/ai/commercial.md) | | — | ☐ | **System messages** — `SystemMessage` + `MessageCenter`, strip above the dock, dedupe by key, short history, never composited | [`ui-surface`](docs/ai/ui-surface.md) | | — | ☐ | **Steam packaging** — rename, `publish` output + `steam_api64.dll`, depot, `steamcmd` upload, store page + assets, DLC app | [`Distribution.md`](Distribution.md) | diff --git a/ViewModels/MainViewModel.OutputProfile.cs b/ViewModels/MainViewModel.OutputProfile.cs new file mode 100644 index 0000000..b86e031 --- /dev/null +++ b/ViewModels/MainViewModel.OutputProfile.cs @@ -0,0 +1,48 @@ +using ytLive.Helpers; + +namespace ytLive.ViewModels; + +/// +/// The entitlement surface: whether this session may emit output above the free budget. +/// +/// Deliberately a separate partial from License.cs: that file owns *how* a +/// licence is validated (Polar today), this one owns *what the answer means* for output. +/// Steam's DLC check and Polar's lease both collapse into ; nothing +/// downstream of cares which one proved it. +/// +public partial class MainViewModel +{ + /// + /// The single input to . + /// + /// Fail closed: an unresolved or unreadable licence is simply false, so a + /// build with no entitlement emits the free profile. There is no "unknown" branch to get + /// wrong. + /// + /// Developer mode never ships: is only ever + /// settable inside a development build (see ), so this expression + /// reduces to IsPremium in a Release binary. + /// + public bool HasProOutput => IsPremium || DeveloperMode; + + private bool _developerMode; + + /// + /// Development-only override: ignore the output budget and allow every shipped tier. + /// + /// There is no UI to reach this in a Release build — the panel that hosts the + /// checkbox is excluded from the Release compile — and the setter refuses the value + /// unless is true, so even a hand-edited settings + /// file cannot unlock it. + /// + public bool DeveloperMode + { + get => _developerMode; + set + { + if (!DevBuild.IsDevelopment) return; + if (!SetProperty(ref _developerMode, value)) return; + OnPropertyChanged(nameof(HasProOutput)); + } + } +} diff --git a/ViewModels/MainViewModel.Scoreboard.cs b/ViewModels/MainViewModel.Scoreboard.cs index 8c8cedf..4238b34 100644 --- a/ViewModels/MainViewModel.Scoreboard.cs +++ b/ViewModels/MainViewModel.Scoreboard.cs @@ -78,20 +78,15 @@ public partial class MainViewModel } } -#if DEBUG - private const bool DevelopmentBuild = true; -#else - private const bool DevelopmentBuild = false; -#endif - /// Can the scoreboard be configured at all? Signed-in creators yes; in a DEBUG build /// always, so the strip can be previewed and judged before there is an account (creator, /// 2026-10-30). public bool CanUseScoreboard => IsConnected || IsDevelopmentBuild; /// True in a DEBUG build. Used only to expose development-only controls, never to - /// change behaviour — the shipped binary differs by their absence, not by a code path. - public bool IsDevelopmentBuild => DevelopmentBuild; + /// change behaviour — the shipped binary differs by their absence, not by a code path. + /// Single source: . + public bool IsDevelopmentBuild => DevBuild.IsDevelopment; private bool _scoreboardSimulate; diff --git a/ViewModels/MainViewModel.Streaming.Operations.cs b/ViewModels/MainViewModel.Streaming.Operations.cs index 70b3d96..ef7807c 100644 --- a/ViewModels/MainViewModel.Streaming.Operations.cs +++ b/ViewModels/MainViewModel.Streaming.Operations.cs @@ -405,10 +405,14 @@ public partial class MainViewModel : ViewModelBase // Full encoder options for the current tier, or null when no RTMP URL is // available — the pump then skips the encoder entirely (TASK 5 fills the seam). + // + // BOTH outputs (RTMP + local MP4) are encoded from this one option set, so the + // output-profile rule is applied here, once: a capped build cannot stream 1080p and + // record it too. See Models/OutputProfile.cs — pixels, not "720p". private EncoderOptions? BuildEncoderOptions() { var url = _rtmpUrlProvider(); - var quality = SelectedQuality; + var quality = OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput); return new EncoderOptions { RtmpUrl = url ?? string.Empty, diff --git a/docs/ai/commercial.md b/docs/ai/commercial.md index 3e6834e..9da098c 100644 --- a/docs/ai/commercial.md +++ b/docs/ai/commercial.md @@ -31,9 +31,15 @@ The cap is a pixel budget plus a frame rate, not the word "720p": | Vertical 9:16 | 720×1280 @30 | 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. One `OutputProfile`, resolved once per session and - consumed by **both** the stream encoder and the recorder — never two checks. + free 1080p 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 + 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, says what it costs, opens diff --git a/docs/ai/ui-surface.md b/docs/ai/ui-surface.md index 6eb3e26..8c3273c 100644 --- a/docs/ai/ui-surface.md +++ b/docs/ai/ui-surface.md @@ -104,6 +104,7 @@ The app is a **director's control surface**, not an editor. This replaces the OB - **Left panel:** `EditingActive` is an explicit `EDIT` toggle, not a mode inferred from `StreamStatus`. On = scene layers + properties; off = YouTube chat. Go Live turns editing off and stopping restores it; recording does not automatically switch it off. The staged scene is the preview/edit target; `LiveScene` is the program output. - **Top bar:** the `EDIT` toggle sits beside the REC/ON-AIR session controls; the brand/gear and account surfaces remain at the sides. The gear's left-click menu remains; right-click opens App Settings directly. +- **Developer affordances are compiled out of release, not hidden.** Any control that must never ship (today: the all-resolutions override) lives in its own `UserControl` whose `Page` and `Compile` items are removed by `ytLive.csproj` when `$(Configuration) != 'Debug'`, and is added to its host at runtime inside `#if DEBUG` (`OverlayHost`). A collapsed row still exists in the visual tree of every shipped copy; "not compiled" is the only claim worth making. Label such rows with `Brush.Attention` (orange) while they are visible in Debug. Guard: `ytLive.Tests/OutputProfileTests.cs` asserts the build rule, and `Helpers/DevBuild.cs` is the single source for "is this a dev build". - **Scene strip:** five fixed-size cards above the preview show the scene name and hotkey; the staged scene carries a `PROGRAM` badge, and staged/live borders use orange. Click stages a scene while idle; while streaming or recording it changes program output and starts the selected transition. - **Preview rail:** stream info, YPP, Socials, and TEST are icon/color tabs on the preview's right edge. Socials opens an animated in-preview drawer; it is no longer a standalone modal window. The bottom live-controls row retains TRAX and the game/mic meters and controls. - **Central monitor:** shows the staged scene while editing and the program scene when editing is off. Preview transitions snapshot and animate the outgoing picture in every mode; output transitions run while streaming or recording. Go Live itself remains a Cut. diff --git a/ytLive.Tests/OutputProfileTests.cs b/ytLive.Tests/OutputProfileTests.cs new file mode 100644 index 0000000..d84a257 --- /dev/null +++ b/ytLive.Tests/OutputProfileTests.cs @@ -0,0 +1,112 @@ +using System; +using System.IO; +using System.Linq; +using System.Xml.Linq; +using Xunit; +using ytLive.Models; + +namespace ytLive.Tests; + +/// +/// The output-profile rule (2026-11-01): a build without Pro may emit at most 921,600 pixels +/// at 30 fps, and this is the only place that decision is made. +/// +/// The one thing that must never break: a cracked or unlicensed build silently gets a +/// *smaller frame*, never a smaller feature set. If a future tier is added above the budget, +/// the last test here fails instead of shipping it to everyone. +/// +public class OutputProfileTests +{ + /// Mirror of MainViewModel.QualityOptions — the shipped tiers. Kept in + /// step deliberately: this is the table the budget has to cover. + private static readonly QualityOption[] Shipped = + { + new("1080p60", 60, 8.0, 1920, 1080), + new("1080p30", 30, 8.0, 1920, 1080), + new("720p60", 60, 6.0, 1280, 720), + new("720p30", 30, 6.0, 1280, 720), + new("Vertical 1080p30", 30, 8.0, 1080, 1920), + new("Vertical 1080p60", 60, 8.0, 1080, 1920), + }; + + private static QualityOption Tier(string name) => Shipped.Single(t => t.Display == name); + + [Fact] + public void A_free_build_resolves_1080p60_down_to_720p30_with_its_own_bitrate() + { + var resolved = OutputProfile.Resolve(Tier("1080p60"), Shipped, pro: false); + + Assert.Equal(1280, resolved.Width); + Assert.Equal(720, resolved.Height); + Assert.Equal(30, resolved.Fps); + // The bitrate follows the profile: a capped user is not offered an 8 Mbps budget. + Assert.Equal(6.0, resolved.Bitrate); + } + + [Fact] + public void A_free_build_leaves_its_own_tier_untouched() + { + var requested = Tier("720p30"); + Assert.Same(requested, OutputProfile.Resolve(requested, Shipped, pro: false)); + } + + [Fact] + public void Pro_passes_every_shipped_tier_through() + { + foreach (var tier in Shipped) + Assert.Same(tier, OutputProfile.Resolve(tier, Shipped, pro: true)); + } + + [Theory] + [InlineData("1080p60")] + [InlineData("1080p30")] + [InlineData("720p60")] + [InlineData("720p30")] + [InlineData("Vertical 1080p30")] + [InlineData("Vertical 1080p60")] + public void No_free_selection_can_emit_more_than_the_budget(string name) + { + var resolved = OutputProfile.Resolve(Tier(name), Shipped, pro: false); + + Assert.True(resolved.Width * resolved.Height <= OutputProfile.FreeMaxPixels, + $"{resolved.Display} resolves to {resolved.Width * resolved.Height} px on a free build"); + Assert.True(resolved.Fps <= OutputProfile.FreeMaxFps, + $"{resolved.Display} resolves to {resolved.Fps} fps on a free build"); + } + + [Fact] + public void An_empty_tier_table_still_yields_a_legal_frame() + { + var resolved = OutputProfile.Resolve(Tier("1080p60"), Array.Empty(), pro: false); + + Assert.Equal(1280, resolved.Width); + Assert.Equal(720, resolved.Height); + Assert.Equal(30, resolved.Fps); + } + + /// + /// The developer override must not exist in a shipped binary. A runtime test cannot prove + /// that (the test host is a Debug build), so this asserts the build rule itself: the + /// panel's XAML and code-behind are removed whenever the configuration is not Debug. + /// + [Fact] + public void The_developer_panel_is_excluded_from_the_release_compile() + { + var project = XDocument.Load(SourceAssert.RepoPath("ytLive.csproj")); + var guards = project.Descendants() + .Where(e => e.Name.LocalName is "Page" or "Compile") + .Where(e => (string?)e.Attribute("Remove") is string remove + && remove.Contains("DeveloperPanel", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + Assert.Equal(2, guards.Length); + foreach (var guard in guards) + { + // The condition may sit on the item or on its ItemGroup — either excludes it. + var condition = (string?)guard.Attribute("Condition") + ?? (string?)guard.Parent?.Attribute("Condition"); + Assert.NotNull(condition); + Assert.Contains("'$(Configuration)' != 'Debug'", condition, StringComparison.Ordinal); + } + } +} diff --git a/ytLive.csproj b/ytLive.csproj index 6052d53..e2069ad 100644 --- a/ytLive.csproj +++ b/ytLive.csproj @@ -92,6 +92,14 @@ + + + + + +