feat(33): the step-down acts — one encoder relaunch, one toast, same URL
Slice 3. 589 green, 0 warnings. - **`FramePump.ChangeEncoderAsync`** is a full pump stop/start, not an encoder swap. A swap leaves an in-flight submit pointing at a disposed process, and that faults the loop — which ends the broadcast, the one thing a quality step-down must never do. `StopAsync` already awaits the loop and flushes, so this reuses the tested path and costs one short freeze, which the ladder accepts. - **`MainViewModel.StepDown`**: one planner per broadcast, subscribed to `StatsReported`, unsubscribed on every stop path. The pump thread only decides; the restart is posted to the dispatcher, because the act stops the loop that raised the event. - **The creator's choice is not rewritten**: `_encoderTierOverride` changes what the encoder emits while the dropdown keeps showing what they picked, and the badge adds `· lowered` so the two never disagree silently. - Copy comes from `StepDownPlanner.Describe` (machine cause owns it, upload cause blames the upload); the log gets the cause-tagged line for support. Test: `EncoderRestartTests` runs a real pump with a fake encoder, changes the tier, and asserts a NEW encoder started at the new fps/bitrate and produced a frame — the regression that would otherwise ship as "it lowered the badge and the stream died". Remaining for 33: shed our own capture cost before the fps step, and the session-report line.
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
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 = OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput);
|
||||
_encoderTierOverride = null;
|
||||
_stepDownPlanner = new StepDownPlanner(
|
||||
new StepDownPolicy(quality.Width, quality.Height, quality.Fps, quality.Bitrate),
|
||||
_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;
|
||||
}
|
||||
|
||||
/// <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
|
||||
{
|
||||
_encoderTierOverride = landed;
|
||||
AppLog.Write($"Quality step-down: {decision.Why}");
|
||||
await _framePump.ChangeEncoderAsync();
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,6 +163,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
if (!TryStartAudioPipe()) return;
|
||||
_liveElapsed = TimeSpan.Zero;
|
||||
_recordLength = TimeSpan.Zero;
|
||||
StartStepDownWatch();
|
||||
|
||||
var recordStarted = false;
|
||||
if (alsoRecord)
|
||||
@@ -405,6 +406,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
private async void StopStream()
|
||||
{
|
||||
CancelStreamRecovery(); // a stop wins over a relaunch in flight
|
||||
StopStepDownWatch();
|
||||
RemoveAttentionAlert("stream-recovery");
|
||||
var wasLive = IsLive;
|
||||
var wasRecording = IsRecording;
|
||||
@@ -494,6 +496,11 @@ public partial class MainViewModel : ViewModelBase
|
||||
{
|
||||
var url = _rtmpUrlProvider();
|
||||
var quality = 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)
|
||||
quality = new QualityOption($"{landed.Width}p{landed.Fps}", landed.Fps,
|
||||
landed.BitrateMbps, landed.Width, landed.Height);
|
||||
var compositor = BuildCompositorOptions();
|
||||
return new EncoderOptions
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user