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:
2026-11-09 12:50:53 -08:00
parent b84802e657
commit 134103c08b
5 changed files with 221 additions and 1 deletions
@@ -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
{