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.
105 lines
4.0 KiB
C#
105 lines
4.0 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Xunit;
|
|
using ytLive.Models;
|
|
using ytLive.Services;
|
|
using ytLive.Services.Compositor;
|
|
using ytLive.Services.Encoder;
|
|
|
|
namespace ytLive.Tests;
|
|
|
|
/// <summary>
|
|
/// TASK 33 slice 3 — the step-down's one dangerous act: relaunching the encoder mid-broadcast.
|
|
///
|
|
/// <para>The restart is a full pump stop/start rather than an encoder swap, and this test is why: a swap
|
|
/// leaves an in-flight submit pointing at a disposed process, which faults the loop and ends the
|
|
/// broadcast. Here the pump must come back and produce a frame with a <b>new</b> encoder instance and
|
|
/// the tier the policy landed on.</para>
|
|
/// </summary>
|
|
public sealed class EncoderRestartTests
|
|
{
|
|
[Fact]
|
|
public async Task ChangeEncoder_RelauchesWithANewEncoderAtTheNewTier()
|
|
{
|
|
var tier = (Width: 1920, Height: 1080, Fps: 60, Bitrate: 8.0);
|
|
var started = new List<EncoderOptions>();
|
|
var frames = new List<CapturingEncoder>();
|
|
|
|
var scene = new Scene { Name = "Live" };
|
|
scene.Elements.Add(new Source { Name = "Background", Type = SourceType.Background, IsBackground = true });
|
|
var black = new VideoFrame(1920, 1080, new byte[1920 * 1080 * 4]);
|
|
|
|
using var pump = new FramePump(
|
|
sceneProvider: () => scene,
|
|
frameResolver: _ => black,
|
|
compositorOptions: () => new CompositorOptions
|
|
{
|
|
SourceRectX = 0, SourceRectY = 0,
|
|
SourceRectWidth = 1920, SourceRectHeight = 1080,
|
|
OutputWidth = 1920, OutputHeight = 1080,
|
|
},
|
|
encoderOptions: () => new EncoderOptions
|
|
{
|
|
RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/abc",
|
|
Width = tier.Width, Height = tier.Height, Fps = tier.Fps,
|
|
BitrateKbps = (int)(tier.Bitrate * 1000),
|
|
},
|
|
encoderFactory: () =>
|
|
{
|
|
var encoder = new CapturingEncoder();
|
|
encoder.Started += options => started.Add(options);
|
|
frames.Add(encoder);
|
|
return encoder;
|
|
},
|
|
log: _ => { },
|
|
pacingDelay: (_, _) => Task.Delay(5));
|
|
|
|
await pump.StartAsync();
|
|
Assert.True(await frames[0].FirstFrame.Task.WaitAsync(TimeSpan.FromSeconds(5)), "first session never produced a frame");
|
|
|
|
// The policy lands on 1080p30 @ 6.4 Mbps; the VM has already written the override, so the
|
|
// options func now reports the new tier — which the restart must re-read.
|
|
tier = (1920, 1080, 30, 6.4);
|
|
|
|
Assert.True(await pump.ChangeEncoderAsync());
|
|
Assert.True(await frames[1].FirstFrame.Task.WaitAsync(TimeSpan.FromSeconds(5)), "restarted session never produced a frame");
|
|
|
|
Assert.Equal(2, started.Count);
|
|
Assert.Equal(60, started[0].Fps);
|
|
Assert.Equal(30, started[1].Fps);
|
|
Assert.Equal(6400, started[1].BitrateKbps);
|
|
Assert.NotSame(frames[0], frames[1]);
|
|
|
|
await pump.StopAsync();
|
|
}
|
|
|
|
private sealed class CapturingEncoder : IFfmpegEncoder
|
|
{
|
|
public readonly TaskCompletionSource<bool> FirstFrame = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
public event Action<EncoderOptions>? Started;
|
|
|
|
public int DroppedFrames => 0;
|
|
|
|
public event EventHandler<StreamHealth>? HealthUpdated { add { } remove { } }
|
|
public event EventHandler<string>? ProcessFailed { add { } remove { } }
|
|
|
|
public Task StartAsync(EncoderOptions options, CancellationToken cancellationToken = default)
|
|
{
|
|
Started?.Invoke(options);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default)
|
|
{
|
|
FirstFrame.TrySetResult(true);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task StopAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
|
|
|
public void Dispose() { }
|
|
}
|
|
}
|