Files
LlamaCasty/ViewModels/MainViewModel.Rate.cs
gramps e654a16b60 fix(history): file each session under the real encoder, not "unknown"
FfmpegEncoder resolved the encoder name into its OWN StreamHealth instance, while the view
model read CurrentHealth — a different object. So every recorded session was filed as
"unknown", and the rate history's encoder key (evidence about NVENC says nothing about QSV)
had nothing to key on. The name now travels on the encoder interface and the pump forwards
it: IFfmpegEncoder.VideoEncoderName -> FramePump.EncoderName.

Caught while adding it: reading _framePump.EncoderName from RefreshRateProposal throws,
because that call runs at MainViewModel ctor line 354 and the pump is built at line 468.
Eight tests failed with the same NullReferenceException, which took a while to see because
the harness's test output is buffered — the first honest look at per-test logging named it
in one line. Guarded; the history falls back to the last session's encoder for that tier,
which is exactly the pre-first-session case the fallback already existed for.

Tests: FramePumpTests gains the encoder-name assertion (null before start, h264_nvenc
after), and the three fake encoders implement the new member.

675 pass, 0 warnings.
2026-11-12 13:57:03 -08:00

173 lines
7.4 KiB
C#

using System.Windows.Input;
using ytLive.Helpers;
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));
}
// ─── Between-session suggestion (Models/RateHistory) ───
//
// Never applied on its own. The creator is asked once per materially different answer and may say
// "keep mine"; that refusal is remembered against the proposed value, so the same question is not
// asked again until the evidence actually moves.
private RateHistory.Proposal? _rateProposal;
public bool HasRateProposal => _rateProposal is not null;
public string? RateProposalText
{
get
{
if (_rateProposal is not { } proposal) return null;
var tier = SelectedQuality.Display;
return proposal.Raise
? $"Your last {RateHistory.Window} sessions on {tier} held {proposal.Mbps:0.#} Mbps. " +
$"Start the next stream there instead of {RatedMbps:0.#}?"
: $"Your last {RateHistory.Window} sessions on {tier} had to drop quality. " +
$"Start the next stream at {proposal.Mbps:0.#} Mbps instead of {RatedMbps:0.#}?";
}
}
public ICommand AcceptRateProposalCommand { get; private set; } = null!;
public ICommand DismissRateProposalCommand { get; private set; } = null!;
private void InitializeRateProposalCommands()
{
AcceptRateProposalCommand = new RelayCommand(_ =>
{
if (_rateProposal is not { } proposal) return;
RatedMbps = proposal.Mbps; // explicit consent is the only path that changes it
ClearRateProposal();
});
DismissRateProposalCommand = new RelayCommand(_ =>
{
if (_rateProposal is { } proposal)
_layoutStore.SaveDismissedRateProposal(SelectedQuality.Display, proposal.Mbps);
ClearRateProposal();
});
}
/// <summary>Recomputes the suggestion for the selected tier. Called at startup and whenever the tier
/// or the rate changes; silent unless the evidence warrants a change and the creator has not already
/// refused this exact number.</summary>
internal void RefreshRateProposal()
{
_rateProposal = null;
if (_layoutStore is not null)
{
var tier = SelectedQuality;
var history = _layoutStore.LoadRateHistory();
// Before this run's first stream the encoder is unknown; the last session on this tier is
// the honest guess, and evidence about a different encoder stays out either way.
var lastForTier = history.LastOrDefault(entry => entry.Tier == tier.Display);
// The pump is built later in the constructor than this runs, so before the first session
// there is no encoder to ask; the last session on this tier is the honest guess, and
// evidence about a different encoder stays out either way.
var encoder = (_framePump is { } pump ? pump.EncoderName : null)
?? (string.IsNullOrEmpty(lastForTier.Encoder) ? null : lastForTier.Encoder);
var suggestion = RateHistory.Suggest(history, tier.Display, encoder, RatedMbps);
if (suggestion is { } candidate
&& _layoutStore.LoadDismissedRateProposal(tier.Display) != candidate.Mbps)
{
_rateProposal = candidate;
}
}
OnPropertyChanged(nameof(HasRateProposal));
OnPropertyChanged(nameof(RateProposalText));
}
private void ClearRateProposal()
{
_rateProposal = null;
OnPropertyChanged(nameof(HasRateProposal));
OnPropertyChanged(nameof(RateProposalText));
}
}