feat(profile): the free budget is pixels and fps, enforced once — with a dev-only release hatch
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.
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
namespace ytLive.Models;
|
||||
|
||||
/// <summary>
|
||||
/// The one entitlement rule, as pure policy: what a build is allowed to emit.
|
||||
///
|
||||
/// <para><b>Why pixels and a frame rate rather than "720p".</b> 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.</para>
|
||||
///
|
||||
/// <para><b>Fail closed.</b> Callers pass <c>pro: false</c> when entitlement is unknown,
|
||||
/// unreadable or unresolved; the free profile is the default state, never the fallback
|
||||
/// after an error.</para>
|
||||
///
|
||||
/// <para><b>This is not a feature gate.</b> 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.</para>
|
||||
/// </summary>
|
||||
public static class OutputProfile
|
||||
{
|
||||
/// <summary>Free ceiling: 921,600 px = 1280×720, i.e. one 720p frame.</summary>
|
||||
public const int FreeMaxPixels = 921_600;
|
||||
|
||||
/// <summary>Free ceiling: 30 frames per second.</summary>
|
||||
public const int FreeMaxFps = 30;
|
||||
|
||||
/// <summary>The named tier a capped build falls back to when nothing else qualifies.</summary>
|
||||
public const string FreeFallbackName = "720p30";
|
||||
|
||||
/// <summary>Is this tier within the free budget? Pro may use anything shipped.</summary>
|
||||
public static bool IsAllowed(QualityOption tier, bool pro)
|
||||
=> pro
|
||||
|| (tier.Width * tier.Height <= FreeMaxPixels && tier.Fps <= FreeMaxFps);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="requested">What the dropdown says.</param>
|
||||
/// <param name="shipped">Every tier the build offers.</param>
|
||||
/// <param name="pro">Entitlement, already resolved. Unknown must be passed as false.</param>
|
||||
public static QualityOption Resolve(
|
||||
QualityOption requested, IEnumerable<QualityOption> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user