feat(quality): the creator sets the rate — per tier, persisted, clamped only at YouTube's floor
The tier table cannot know a creator's connection, so it should not pretend to. The Mbps box under the picker is theirs: the tier chooses geometry and frame rate, the box chooses bits. Stored per tier (`Video.Rate.<tier>`), so tuning 1080p60 does not touch 720p30, and none means "the shipped default" rather than zero. Clamped at the tier's YouTube minimum (`RateLadder`), because a target under it cannot hold the resolution; no ceiling otherwise, because a wrong guess is exactly what live step-down exists to catch, and a creator who types 25 on a fibre line should be allowed to find out. The tooltip carries YouTube's number for the tier. `MainViewModel.EffectiveQuality` is the single substitution point, and the encoder options, the step-down policy and the clip buffer all read it — so a rated tier can never stream at the shipped default because one call site was missed. One ordering trap found by the test: the constructor resolves the default tier before the layout store is open, so a read at that point must not cache, or the shipped default shadows the creator's stored rate for the whole run. 670 pass, 0 warnings.
This commit is contained in:
@@ -145,6 +145,20 @@
|
||||
ItemContainerStyle="{StaticResource YtQualityComboBoxItem}"
|
||||
ToolTip="{Binding ResolutionHelp}"
|
||||
IsEnabled="{Binding CanChangeQuality}"/>
|
||||
<!-- The creator's own rate: the tier sets the geometry, this sets the bits.
|
||||
Guidance lives in the tooltip (YouTube's numbers, from RateLadder). -->
|
||||
<Grid Margin="0,6,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox x:Name="RatedMbpsBox" Grid.Column="0" Style="{StaticResource YtTextBoxCompact}"
|
||||
Text="{Binding RatedMbps, UpdateSourceTrigger=LostFocus, StringFormat={}{0:0.#}}"
|
||||
ToolTip="{Binding RateHint}"
|
||||
IsEnabled="{Binding CanChangeQuality}"/>
|
||||
<TextBlock Grid.Column="1" Text="Mbps" Margin="6,0,0,0" VerticalAlignment="Center"
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
@@ -27,6 +27,26 @@ public partial class LayoutStore : IDisposable
|
||||
public void SaveStickyAlertsUntilDismissed(bool enabled)
|
||||
=> UpsertSetting("Notifications.StickyUntilDismissed", enabled ? "1" : "0");
|
||||
|
||||
/// <summary>The creator's own video rate for one tier, in Mbps, or null to use the shipped default.
|
||||
/// Per tier because each has its own ceiling; the rate is a target the encoder is asked to hold,
|
||||
/// so a stored number means the same thing on every run (CBR).</summary>
|
||||
public double? LoadRatedMbps(string tierDisplay)
|
||||
=> double.TryParse(GetSetting($"Video.Rate.{tierDisplay}"), out var mbps) && mbps > 0 ? mbps : null;
|
||||
|
||||
public void SaveRatedMbps(string tierDisplay, double? mbps)
|
||||
{
|
||||
if (mbps is not > 0)
|
||||
{
|
||||
using var delete = _connection.CreateCommand();
|
||||
delete.CommandText = "DELETE FROM Settings WHERE Key = $key;";
|
||||
delete.Parameters.AddWithValue("$key", $"Video.Rate.{tierDisplay}");
|
||||
delete.ExecuteNonQuery();
|
||||
return;
|
||||
}
|
||||
|
||||
UpsertSetting($"Video.Rate.{tierDisplay}", mbps.Value.ToString("0.#"));
|
||||
}
|
||||
|
||||
private static bool IsSupportedNotificationPosition(NotificationPosition position)
|
||||
=> position is NotificationPosition.TopLeft or NotificationPosition.TopRight
|
||||
or NotificationPosition.CenterLeft or NotificationPosition.CenterRight
|
||||
|
||||
@@ -99,7 +99,7 @@ public partial class MainViewModel
|
||||
return;
|
||||
}
|
||||
|
||||
var quality = Models.OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput);
|
||||
var quality = EffectiveQuality(Models.OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput));
|
||||
var bytes = ClipLibrary.RingBytesFor(_clipSeconds, (int)Math.Round(quality.Bitrate * 1000));
|
||||
if (_clipRing is null || _clipRingCapacity != bytes)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// The creator's own video rate, per tier — the latitude the tier table deliberately does not take
|
||||
/// away. A creator who knows their upload can set any rate from the tier's YouTube minimum upward;
|
||||
/// nothing here invents a ceiling, because a wrong guess is what live step-down exists to catch.
|
||||
///
|
||||
/// <para><b>Why per tier:</b> a number that is sensible at 1080p60 is wasteful at 720p30, and the
|
||||
/// creator who tuned one tier has not tuned the other. Unset means "use the shipped default", which
|
||||
/// is what <see cref="EffectiveQuality"/> substitutes.</para>
|
||||
///
|
||||
/// <para><b>Why this matters now:</b> with CBR holding the target (<c>FfmpegArgs.AddRateControl</c>)
|
||||
/// the number the creator types is the number that goes out, so it is a real quality choice rather
|
||||
/// than an average to undershoot.</para>
|
||||
/// </summary>
|
||||
public partial class MainViewModel
|
||||
{
|
||||
private readonly Dictionary<string, double> _ratedMbpsByTier = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>The tier with the creator's rate substituted for the shipped default. Every consumer
|
||||
/// that builds encoder options or a step-down policy must use this, never the raw tier.</summary>
|
||||
public QualityOption EffectiveQuality(QualityOption tier)
|
||||
=> RatedFor(tier) is var rated && Math.Abs(rated - tier.Bitrate) > 0.001
|
||||
? tier with { Bitrate = rated }
|
||||
: tier;
|
||||
|
||||
private double RatedFor(QualityOption tier)
|
||||
{
|
||||
if (_ratedMbpsByTier.TryGetValue(tier.Display, out var cached)) return cached;
|
||||
|
||||
// The constructor resolves the default tier before the layout store exists, so the first read
|
||||
// must not cache: a cached default would shadow the creator's stored rate for that run.
|
||||
var stored = _layoutStore is null ? null : _layoutStore.LoadRatedMbps(tier.Display);
|
||||
var value = Math.Max(stored ?? tier.Bitrate, RateLadder.For(tier).Minimum);
|
||||
if (_layoutStore is not null) _ratedMbpsByTier[tier.Display] = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>The rate the selected tier will stream at, in Mbps.</summary>
|
||||
public double RatedMbps
|
||||
{
|
||||
get => RatedFor(SelectedQuality);
|
||||
set
|
||||
{
|
||||
var tier = SelectedQuality;
|
||||
var band = RateLadder.For(tier);
|
||||
// Below the tier minimum YouTube will not hold the resolution; above ~100 is not a stream.
|
||||
var clamped = Math.Clamp(Math.Round(value, 1), band.Minimum, 100.0);
|
||||
if (Math.Abs(clamped - RatedFor(tier)) < 0.05)
|
||||
{
|
||||
OnPropertyChanged(nameof(RatedMbps));
|
||||
return;
|
||||
}
|
||||
|
||||
_ratedMbpsByTier[tier.Display] = clamped;
|
||||
_layoutStore.SaveRatedMbps(tier.Display, clamped);
|
||||
ApplyStreamQuality(tier);
|
||||
OnPropertyChanged(nameof(RatedMbps));
|
||||
OnPropertyChanged(nameof(RateHint));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What YouTube expects for this tier, shown next to the field.</summary>
|
||||
public string RateHint
|
||||
{
|
||||
get
|
||||
{
|
||||
var band = RateLadder.For(SelectedQuality);
|
||||
return $"YouTube recommends {band.Recommended:0.#} Mbps for {SelectedQuality.Display}; " +
|
||||
$"it will not take less than {band.Minimum:0.#}. Your connection must carry this " +
|
||||
"plus ~20% headroom.";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Restores the shipped default for the selected tier.</summary>
|
||||
public void ResetRatedMbps()
|
||||
{
|
||||
var tier = SelectedQuality;
|
||||
if (!_ratedMbpsByTier.ContainsKey(tier.Display)) return;
|
||||
_ratedMbpsByTier.Remove(tier.Display);
|
||||
_layoutStore.SaveRatedMbps(tier.Display, null);
|
||||
_ratedMbpsByTier[tier.Display] = RatedFor(tier);
|
||||
ApplyStreamQuality(tier);
|
||||
OnPropertyChanged(nameof(RatedMbps));
|
||||
OnPropertyChanged(nameof(RateHint));
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ public partial class MainViewModel
|
||||
/// <summary>Starts the watch for this broadcast. Called once per session, before the pump starts.</summary>
|
||||
private void StartStepDownWatch()
|
||||
{
|
||||
var quality = OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput);
|
||||
var quality = EffectiveQuality(OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput));
|
||||
_encoderTierOverride = null;
|
||||
_stepDownPlanner = new StepDownPlanner(
|
||||
new StepDownPolicy(quality.Width, quality.Height, quality.Fps, quality.Bitrate,
|
||||
|
||||
@@ -504,7 +504,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
private EncoderOptions? BuildEncoderOptions()
|
||||
{
|
||||
var url = _rtmpUrlProvider();
|
||||
var quality = OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput);
|
||||
var quality = EffectiveQuality(OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput));
|
||||
// TASK 33: while a session is live, a step-down owns the emitted tier even though the dropdown
|
||||
// still shows what the creator picked. Their intent is not edited by the policy.
|
||||
if (_encoderTierOverride is { } landed)
|
||||
|
||||
@@ -488,7 +488,11 @@ public partial class MainViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
if (SetProperty(ref _selectedQuality, value))
|
||||
{
|
||||
ApplyStreamQuality(value);
|
||||
OnPropertyChanged(nameof(RatedMbps));
|
||||
OnPropertyChanged(nameof(RateHint));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -505,7 +509,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
private void ApplyStreamQuality(QualityOption quality)
|
||||
{
|
||||
var health = CurrentHealth;
|
||||
health.CurrentBitrate = quality.Bitrate;
|
||||
health.CurrentBitrate = EffectiveQuality(quality).Bitrate;
|
||||
health.FPS = quality.Fps;
|
||||
OnPropertyChanged(nameof(CurrentHealth));
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ Placement math itself is covered by `WindowPlacementTests` (pure function, no HW
|
||||
geometry fields are 44px: the measured width of `-1920` in the app's font at 11px (via WPF's
|
||||
`FormattedText` — **measure, don't guess**) plus the field's padding and border. Guard:
|
||||
`ytLive.Tests/GeometryFieldWidthTests.cs`
|
||||
- Resolution tiers: 1080p60@12 (default) → 1080p30@10 → 720p60@6 → 720p30@6 → **Vertical 1080p30@10 and 1080p60@12 (9:16, 1080×1920)** — the `@` is the CBR target the encoder is asked to hold, between YouTube's H.264 recommendation and the practitioner sweet spot (its 14/17/8 recommendations are the max, the tier minimums 5/6/3 the step-down floor). Vertical mirrors its landscape pair: same pixel count, same rate. The old flat 8 Mbps was an inherited round number, not a measurement. The composition master frame is **always 1920×1080** — a tier is an output rect + target resolution over that master, so source geometry is never rewritten (no rounding drift). 16:9 tiers use the full frame; vertical tiers use a centered **607×1080** window and the preview dims the cropped side strips at 55% black with an accent outline (semi-crop — the cut area stays visible). A resolution badge in the preview corner shows the active tier. The resolution picker and live readouts (bitrate/FPS/dropped/duration) are in the top-left cell above the layers panel; the bottom bar is an empty height-preserving spacer. A **tooltip** explains finding upload bandwidth — an in-app speed test was deliberately dropped (unreliable). The future encoder crops the master to the rect and scales to the tier's Width×Height
|
||||
- Resolution tiers: 1080p60@12 (default) → 1080p30@10 → 720p60@6 → 720p30@6 → **Vertical 1080p30@10 and 1080p60@12 (9:16, 1080×1920)** — the `@` is the CBR target the encoder is asked to hold, between YouTube's H.264 recommendation and the practitioner sweet spot (its 14/17/8 recommendations are the max, the tier minimums 5/6/3 the step-down floor). Vertical mirrors its landscape pair: same pixel count, same rate. The old flat 8 Mbps was an inherited round number, not a measurement. The composition master frame is **always 1920×1080** — a tier is an output rect + target resolution over that master, so source geometry is never rewritten (no rounding drift). 16:9 tiers use the full frame; vertical tiers use a centered **607×1080** window and the preview dims the cropped side strips at 55% black with an accent outline (semi-crop — the cut area stays visible). A resolution badge in the preview corner shows the active tier. The resolution picker and live readouts (bitrate/FPS/dropped/duration) are in the top-left cell above the layers panel; the bottom bar is an empty height-preserving spacer. Under the picker sits the creator's own **rate box** (Mbps, per tier, persisted as `Video.Rate.<tier>`): the tier chooses geometry, the box chooses bits, clamped at the tier's YouTube minimum and otherwise unbounded because live step-down is the thing that catches a wrong guess. Its tooltip carries YouTube's recommendation for the tier (`Models/RateLadder`). Every consumer — encoder options, step-down policy, clip buffer — reads `MainViewModel.EffectiveQuality`, never the raw tier. A **tooltip** explains finding upload bandwidth — an in-app speed test was deliberately dropped (unreliable). The future encoder crops the master to the rect and scales to the tier's Width×Height
|
||||
- **Locked output tiers stay visible and clickable.** They are dimmed for non-owners. Choosing one keeps the free tier selected and opens About in-app; only the explicit llamacasty.com link opens a browser.
|
||||
- Crash diagnosis: `AppLog` writes startup checkpoints to `%APPDATA%\ytLlive\startup.log`; `App.xaml.cs` logs `DispatcherUnhandledException`/`AppDomain.UnhandledException`. When WPF won't run from WSL, this log is how you find the failure (it caught the `MenuItemRole.Separator` XAML crash and the ComboBox SelectionBoxItem bug). **The log is SIZE-CAPPED (TASK 55, 2026-10-22):** at 5 MB `startup.log` rolls to `startup.1.log` (one rolled copy kept) — the policy lives in `Helpers/LogRotation.cs` as a pure function over a path, so `AppLog`'s 156-call-site surface never changes and the rule is testable on its own. Guard: `ytLive.Tests/AppLogRotationTests.cs`
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The creator's own video rate: per tier, clamped at the tier's YouTube minimum, persisted across
|
||||
/// runs, and substituted into every consumer that builds encoder options.
|
||||
///
|
||||
/// <para>The rule that must never break: whatever the creator typed is what the encoder is asked to
|
||||
/// hold, and no tier's minimum is quietly undercut.</para>
|
||||
/// </summary>
|
||||
[Collection("MainViewModelLayoutPath")]
|
||||
public class RatedBitrateTests
|
||||
{
|
||||
[Fact]
|
||||
public void The_creator_rate_is_per_tier_clamped_to_the_minimum_and_restored()
|
||||
{
|
||||
var tempDb = Path.Combine(Path.GetTempPath(), $"llamacasty-rate-{Guid.NewGuid():N}.db");
|
||||
MainViewModel.LayoutPathOverride = tempDb;
|
||||
try
|
||||
{
|
||||
var vm = new MainViewModel();
|
||||
var tier60 = vm.QualityOptions.Single(tier => tier.Display == "1080p60");
|
||||
var tier30 = vm.QualityOptions.Single(tier => tier.Display == "1080p30");
|
||||
vm.SelectedQuality = tier60;
|
||||
|
||||
// Unset means the shipped default, not zero.
|
||||
Assert.Equal(tier60.Bitrate, vm.RatedMbps);
|
||||
|
||||
// A creator who knows their line sets their own number, and the encoder sees it.
|
||||
vm.RatedMbps = 15;
|
||||
Assert.Equal(15, vm.RatedMbps);
|
||||
Assert.Equal(15, vm.EffectiveQuality(tier60).Bitrate);
|
||||
Assert.Equal(60, vm.EffectiveQuality(tier60).Fps); // geometry stays the tier's
|
||||
|
||||
// Below YouTube's minimum for the tier is not a legal target.
|
||||
vm.RatedMbps = 2;
|
||||
Assert.Equal(RateLadder.For(tier60).Minimum, vm.RatedMbps);
|
||||
|
||||
// The other tier keeps its own value — one setting does not leak into the ladder.
|
||||
vm.SelectedQuality = tier30;
|
||||
Assert.Equal(tier30.Bitrate, vm.RatedMbps);
|
||||
vm.SelectedQuality = tier60;
|
||||
|
||||
// And it survives a restart: the same profile, a fresh view model.
|
||||
var reloaded = new MainViewModel();
|
||||
reloaded.SelectedQuality = reloaded.QualityOptions.Single(tier => tier.Display == "1080p60");
|
||||
Assert.Equal(RateLadder.For(tier60).Minimum, reloaded.RatedMbps);
|
||||
}
|
||||
finally
|
||||
{
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
try { File.Delete(tempDb); } catch (IOException) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user