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.
106 lines
4.2 KiB
C#
106 lines
4.2 KiB
C#
using System;
|
||
using System.Windows;
|
||
using ytLive.Helpers;
|
||
using ytLive.Models;
|
||
using ytLive.Services;
|
||
using ytLive.Services.Encoder;
|
||
|
||
namespace ytLive.ViewModels;
|
||
|
||
/// <summary>
|
||
/// TASK 33, slice 3: the session's step-down watch — policy feed, one encoder relaunch, one toast.
|
||
///
|
||
/// <para>Kept in its own file because it is the only place that both reads the pump's stats and acts on
|
||
/// the session; the pure decision lives in <see cref="StepDownPolicy"/>, the sentence in
|
||
/// <see cref="StepDownPlanner"/>.</para>
|
||
/// </summary>
|
||
public partial class MainViewModel
|
||
{
|
||
private StepDownPlanner? _stepDownPlanner;
|
||
private (int Width, int Height, int Fps, double BitrateMbps)? _encoderTierOverride;
|
||
private bool _stepDownBusy;
|
||
|
||
/// <summary>Starts the watch for this broadcast. Called once per session, before the pump starts.</summary>
|
||
private void StartStepDownWatch()
|
||
{
|
||
var quality = EffectiveQuality(OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput));
|
||
_encoderTierOverride = null;
|
||
_stepDownPlanner = new StepDownPlanner(
|
||
new StepDownPolicy(quality.Width, quality.Height, quality.Fps, quality.Bitrate,
|
||
floorBitrateMbps: RateLadder.For(quality).Minimum),
|
||
_sessionStartedAtUtc ?? DateTime.UtcNow);
|
||
_framePump.StatsReported += OnPumpStatsReported;
|
||
}
|
||
|
||
/// <summary>Ends the watch. Called from StopStream on every path, so a stale planner can never
|
||
/// carry a spent budget into the next broadcast.</summary>
|
||
private void StopStepDownWatch()
|
||
{
|
||
_framePump.StatsReported -= OnPumpStatsReported;
|
||
_stepDownPlanner = null;
|
||
_encoderTierOverride = null;
|
||
_stepDownBusy = false;
|
||
_screenCaptureManager.ShedLoad(false); // never carry a shed cadence into the next show
|
||
}
|
||
|
||
/// <summary>Runs on the PUMP thread, so it must never block: the restart it may trigger stops the
|
||
/// very loop that raised this event. Decide here (pure and fast), act on the dispatcher.</summary>
|
||
private void OnPumpStatsReported(object? sender, FramePumpStats stats)
|
||
{
|
||
if (_stepDownPlanner is not { } planner) return;
|
||
|
||
var decision = planner.Observe(stats, DateTime.UtcNow);
|
||
if (!decision.Acted) return;
|
||
|
||
var dispatcher = Application.Current?.Dispatcher;
|
||
if (dispatcher == null) return;
|
||
_ = dispatcher.InvokeAsync(() => ApplyStepDownAsync(decision));
|
||
}
|
||
|
||
private async void ApplyStepDownAsync(StepDownDecision decision)
|
||
{
|
||
var planner = _stepDownPlanner;
|
||
if (planner == null || _stepDownBusy) return;
|
||
|
||
_stepDownBusy = true;
|
||
var landed = planner.Current;
|
||
try
|
||
{
|
||
if (decision.Action == StepAction.ShedCaptureCadence)
|
||
{
|
||
_screenCaptureManager.ShedLoad(true);
|
||
AppLog.Write($"Capture load shed: {decision.Why}");
|
||
return;
|
||
}
|
||
|
||
_encoderTierOverride = landed;
|
||
AppLog.Write($"Quality step-down: {decision.Why}");
|
||
await _framePump.ChangeEncoderAsync(
|
||
_audioMixer.StopLive,
|
||
() => _audioMixer.StartLive(InstanceProfile.AudioPipeName(EncoderOptions.DefaultAudioPipeName)));
|
||
|
||
// Show what is actually going out. The dropdown keeps the creator's choice — the policy
|
||
// does not get to rewrite their intent, only the encoder's output.
|
||
CurrentHealth.FPS = landed.Fps;
|
||
CurrentHealth.CurrentBitrate = landed.BitrateMbps;
|
||
ResolutionBadgeText = $"{landed.Width}×{landed.Height} @ {landed.Fps} fps · lowered";
|
||
OnPropertyChanged(nameof(CurrentHealth));
|
||
OnPropertyChanged(nameof(ResolutionBadgeText));
|
||
|
||
var copy = StepDownPlanner.Describe(decision, landed);
|
||
if (copy.Length > 0)
|
||
_notifications.Info("Quality lowered", copy);
|
||
|
||
AppLog.Write($"Quality step-down complete: {StepDownPlanner.ReportLine(DateTime.Now, decision)}");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
AppLog.Write($"Quality step-down failed: {ex.Message}", AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||
}
|
||
finally
|
||
{
|
||
_stepDownBusy = false;
|
||
}
|
||
}
|
||
}
|