diff --git a/MainWindow.xaml b/MainWindow.xaml index fdbedac..3b469b9 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -145,6 +145,20 @@ ItemContainerStyle="{StaticResource YtQualityComboBoxItem}" ToolTip="{Binding ResolutionHelp}" IsEnabled="{Binding CanChangeQuality}"/> + + + + + + + + + diff --git a/Services/LayoutStore.Settings.cs b/Services/LayoutStore.Settings.cs index 4c43f8c..8899bcd 100644 --- a/Services/LayoutStore.Settings.cs +++ b/Services/LayoutStore.Settings.cs @@ -27,6 +27,26 @@ public partial class LayoutStore : IDisposable public void SaveStickyAlertsUntilDismissed(bool enabled) => UpsertSetting("Notifications.StickyUntilDismissed", enabled ? "1" : "0"); + /// 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). + 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 diff --git a/ViewModels/MainViewModel.Clips.cs b/ViewModels/MainViewModel.Clips.cs index 28f08da..3ad18e1 100644 --- a/ViewModels/MainViewModel.Clips.cs +++ b/ViewModels/MainViewModel.Clips.cs @@ -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) { diff --git a/ViewModels/MainViewModel.Rate.cs b/ViewModels/MainViewModel.Rate.cs new file mode 100644 index 0000000..cd4b510 --- /dev/null +++ b/ViewModels/MainViewModel.Rate.cs @@ -0,0 +1,89 @@ +using ytLive.Models; + +namespace ytLive.ViewModels; + +/// +/// 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. +/// +/// Why per tier: 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 substitutes. +/// +/// Why this matters now: with CBR holding the target (FfmpegArgs.AddRateControl) +/// the number the creator types is the number that goes out, so it is a real quality choice rather +/// than an average to undershoot. +/// +public partial class MainViewModel +{ + private readonly Dictionary _ratedMbpsByTier = new(StringComparer.Ordinal); + + /// 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. + 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; + } + + /// The rate the selected tier will stream at, in Mbps. + 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)); + } + } + + /// What YouTube expects for this tier, shown next to the field. + 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."; + } + } + + /// Restores the shipped default for the selected tier. + 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)); + } +} diff --git a/ViewModels/MainViewModel.StepDown.cs b/ViewModels/MainViewModel.StepDown.cs index c37a3a0..43d7179 100644 --- a/ViewModels/MainViewModel.StepDown.cs +++ b/ViewModels/MainViewModel.StepDown.cs @@ -23,7 +23,7 @@ public partial class MainViewModel /// Starts the watch for this broadcast. Called once per session, before the pump starts. 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, diff --git a/ViewModels/MainViewModel.Streaming.Operations.cs b/ViewModels/MainViewModel.Streaming.Operations.cs index f36878f..461211a 100644 --- a/ViewModels/MainViewModel.Streaming.Operations.cs +++ b/ViewModels/MainViewModel.Streaming.Operations.cs @@ -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) diff --git a/ViewModels/MainViewModel.Streaming.cs b/ViewModels/MainViewModel.Streaming.cs index 38ab1b8..9273ab9 100644 --- a/ViewModels/MainViewModel.Streaming.cs +++ b/ViewModels/MainViewModel.Streaming.cs @@ -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)); diff --git a/docs/ai/ui-surface.md b/docs/ai/ui-surface.md index f5bc984..a8e4222 100644 --- a/docs/ai/ui-surface.md +++ b/docs/ai/ui-surface.md @@ -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.`): 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` diff --git a/ytLive.Tests/RatedBitrateTests.cs b/ytLive.Tests/RatedBitrateTests.cs new file mode 100644 index 0000000..1323a2d --- /dev/null +++ b/ytLive.Tests/RatedBitrateTests.cs @@ -0,0 +1,61 @@ +using System; +using System.IO; +using System.Linq; +using Xunit; +using ytLive.Models; +using ytLive.ViewModels; + +namespace ytLive.Tests; + +/// +/// 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. +/// +/// 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. +/// +[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) { } + } + } +}