TASK 4 ship step 5: live frame pipeline — FramePump paces the active scene's composite into the encoder, ScreenCaptureManager.GetLatestFrame, MainViewModel resolver/option-builder/pump wiring, FramePumpTests (7) + GetLatestFrame test — 147 tests passing, 0 warnings
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services.Compositor;
|
||||
|
||||
namespace ytLive.Services.Encoder;
|
||||
|
||||
/// <summary>
|
||||
/// The live frame producer (TASK 4 ship step 5): the bridge between the capture
|
||||
/// managers + compositor and the encoder. While live it snapshots the active
|
||||
/// scene each tick, resolves every element to its latest frame, composites it
|
||||
/// into the tier's output frame, and paces frames into the encoder at the tier's
|
||||
/// FPS. All collaborators are constructor-injected seams (scene, resolver,
|
||||
/// options, encoder factory, pacing delay) so the pump stays free of WPF and of
|
||||
/// the capture managers and is fully hermetic in tests.
|
||||
///
|
||||
/// The RTMP URL comes from the options provider: until the live-stream create
|
||||
/// flow lands (TASK 5) it yields null, so go-live runs the existing visual flow
|
||||
/// without actually pushing.
|
||||
/// </summary>
|
||||
public sealed class FramePump : IDisposable
|
||||
{
|
||||
private readonly Func<Scene?> _sceneProvider;
|
||||
private readonly Func<SceneElement, VideoFrame?> _frameResolver;
|
||||
private readonly Func<CompositorOptions> _compositorOptions;
|
||||
private readonly Func<EncoderOptions?> _encoderOptions;
|
||||
private readonly Func<IFfmpegEncoder> _encoderFactory;
|
||||
private readonly Action<string>? _log;
|
||||
private readonly Func<TimeSpan, CancellationToken, Task> _pacingDelay;
|
||||
private readonly SceneCompositor _compositor = new();
|
||||
|
||||
private readonly object _gate = new();
|
||||
private IFfmpegEncoder? _encoder;
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _pumpTask;
|
||||
private bool _started;
|
||||
|
||||
/// <summary>Forwards the encoder's parsed health — ship step 6 binds this to the bottom bar.</summary>
|
||||
public event EventHandler<StreamHealth>? HealthUpdated;
|
||||
|
||||
/// <summary>Raised when the encoder cannot start or dies mid-stream. The pump stops itself.</summary>
|
||||
public event EventHandler<string>? Failed;
|
||||
|
||||
public FramePump(
|
||||
Func<Scene?> sceneProvider,
|
||||
Func<SceneElement, VideoFrame?> frameResolver,
|
||||
Func<CompositorOptions> compositorOptions,
|
||||
Func<EncoderOptions?> encoderOptions,
|
||||
Func<IFfmpegEncoder> encoderFactory,
|
||||
Action<string>? log = null,
|
||||
Func<TimeSpan, CancellationToken, Task>? pacingDelay = null)
|
||||
{
|
||||
_sceneProvider = sceneProvider ?? throw new ArgumentNullException(nameof(sceneProvider));
|
||||
_frameResolver = frameResolver ?? throw new ArgumentNullException(nameof(frameResolver));
|
||||
_compositorOptions = compositorOptions ?? throw new ArgumentNullException(nameof(compositorOptions));
|
||||
_encoderOptions = encoderOptions ?? throw new ArgumentNullException(nameof(encoderOptions));
|
||||
_encoderFactory = encoderFactory ?? throw new ArgumentNullException(nameof(encoderFactory));
|
||||
_log = log;
|
||||
_pacingDelay = pacingDelay ?? ((delay, ct) => Task.Delay(delay, ct));
|
||||
}
|
||||
|
||||
public bool IsRunning { get; private set; }
|
||||
|
||||
/// <summary>Never throws: failures are logged and surfaced via <see cref="Failed"/>,
|
||||
/// so the VM can fire-and-forget it from a sync command handler.</summary>
|
||||
public async Task StartAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_started) return;
|
||||
_started = true;
|
||||
}
|
||||
|
||||
IFfmpegEncoder? encoder = null;
|
||||
try
|
||||
{
|
||||
var options = _encoderOptions();
|
||||
if (options == null)
|
||||
{
|
||||
_log?.Invoke("FramePump: no RTMP URL available (live-stream create lands in TASK 5) — encoder skipped");
|
||||
lock (_gate) _started = false;
|
||||
return;
|
||||
}
|
||||
|
||||
encoder = _encoderFactory();
|
||||
encoder.HealthUpdated += OnHealthUpdated;
|
||||
encoder.ProcessFailed += OnProcessFailed;
|
||||
await encoder.StartAsync(options, cancellationToken);
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_encoder = encoder;
|
||||
}
|
||||
|
||||
// IsRunning must be true before the loop starts: the loop reads it on
|
||||
// its first iteration, and with a completed-task delay it can run
|
||||
// synchronously on this thread before PumpAsync even returns.
|
||||
IsRunning = true;
|
||||
_cts = new CancellationTokenSource();
|
||||
_pumpTask = PumpAsync(options, _cts.Token);
|
||||
_log?.Invoke($"FramePump started ({options.Width}×{options.Height} @ {options.Fps} fps)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.Invoke($"FramePump: start failed: {ex.Message}");
|
||||
if (encoder != null)
|
||||
{
|
||||
encoder.HealthUpdated -= OnHealthUpdated;
|
||||
encoder.ProcessFailed -= OnProcessFailed;
|
||||
try
|
||||
{
|
||||
encoder.Dispose();
|
||||
}
|
||||
catch (Exception disposeEx)
|
||||
{
|
||||
_log?.Invoke($"FramePump: disposing failed encoder: {disposeEx.Message}");
|
||||
}
|
||||
}
|
||||
lock (_gate)
|
||||
{
|
||||
_started = false;
|
||||
IsRunning = false;
|
||||
}
|
||||
Failed?.Invoke(this, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
IFfmpegEncoder? encoder;
|
||||
Task? pump;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_started && _encoder == null) return;
|
||||
_started = false;
|
||||
IsRunning = false;
|
||||
encoder = _encoder;
|
||||
pump = _pumpTask;
|
||||
_cts?.Cancel();
|
||||
}
|
||||
|
||||
// Stop the encoder BEFORE awaiting the pump: closing its stdin unblocks a
|
||||
// write stuck on pipe backpressure, otherwise the pump could await forever.
|
||||
if (encoder != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await encoder.StopAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.Invoke($"FramePump: encoder stop failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (pump != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await pump;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.Invoke($"FramePump: pump loop faulted during stop: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (encoder != null)
|
||||
{
|
||||
encoder.HealthUpdated -= OnHealthUpdated;
|
||||
encoder.ProcessFailed -= OnProcessFailed;
|
||||
try
|
||||
{
|
||||
encoder.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.Invoke($"FramePump: encoder dispose failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_encoder = null;
|
||||
_cts = null;
|
||||
_pumpTask = null;
|
||||
}
|
||||
_log?.Invoke("FramePump stopped");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
StopAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.Invoke($"FramePump: dispose failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PumpAsync(EncoderOptions options, CancellationToken ct)
|
||||
{
|
||||
var interval = TimeSpan.FromSeconds(1d / Math.Max(1, options.Fps));
|
||||
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var scene = _sceneProvider();
|
||||
if (scene != null)
|
||||
{
|
||||
var frame = _compositor.Render(scene, _frameResolver, null, _compositorOptions());
|
||||
IFfmpegEncoder? encoder;
|
||||
lock (_gate) encoder = _encoder;
|
||||
if (encoder == null) break; // _encoder is only cleared after the loop ends; defensive
|
||||
await encoder.SubmitFrameAsync(frame, ct);
|
||||
}
|
||||
await _pacingDelay(interval, ct);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// normal stop
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A failure while the pump is supposed to run (encoder died under us,
|
||||
// scene provider faulted, ...) stops the pump and surfaces once.
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
_log?.Invoke($"FramePump: pump exited during stop: {ex.Message}");
|
||||
}
|
||||
else
|
||||
{
|
||||
_log?.Invoke($"FramePump: pump loop faulted: {ex.Message}");
|
||||
Failed?.Invoke(this, ex.Message);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_gate) IsRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnHealthUpdated(object? sender, StreamHealth health) => HealthUpdated?.Invoke(this, health);
|
||||
|
||||
private void OnProcessFailed(object? sender, string message)
|
||||
{
|
||||
_log?.Invoke($"FramePump: encoder process failed: {message}");
|
||||
Failed?.Invoke(this, message);
|
||||
_ = StopAsync();
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,15 @@ public sealed class ScreenCaptureManager : IDisposable
|
||||
toStop.PreviewBitmap = null;
|
||||
}
|
||||
|
||||
/// <summary>The most recent frame for a capture key, or null before the first
|
||||
/// frame arrives (or if the key has no session). The live compositor reads
|
||||
/// the backdrop from here.</summary>
|
||||
public VideoFrame? GetLatestFrame(string key)
|
||||
{
|
||||
lock (_gate)
|
||||
return _sessions.TryGetValue(key, out var session) ? session.LatestFrame : null;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
List<CaptureSession> sessions;
|
||||
|
||||
+3
-2
@@ -35,13 +35,14 @@ External-facing logic: YouTube API, persistence. See
|
||||
| `SocialValidator.cs` | `ISocialValidator` seam + `HttpSocialValidator` default: validates a social handle/URL by constructing the canonical profile URL and issuing an HTTP GET — the "act of validation" before adding to the bar. 200/redirect = exists; 404/connection failure = rejected. Fediverse `@user@domain` additionally does a best-effort nodeinfo lookup (`/.well-known/nodeinfo` → `software.name`) so the entry can show the instance's real logo; nodeinfo failure still validates (generic fediverse glyph). If the identity domain's nodeinfo is blocked (SSO) but the bare root 302s to the real instance (YunoHost default-app subdomains), the lookup follows the redirect and asks that host instead. Constructor takes optional `HttpClient` for tests |
|
||||
| `Encoder/EncoderOptions.cs` | One go-live's encoder config: full `RtmpUrl` (ingest + stream key), W×H/FPS/bitrate from the quality tier, `VideoEncoder` (null = probe), audio sample rate/channels, `GopSize` = FPS×4 (the ≤4s keyframe bound) |
|
||||
| `Encoder/IFfmpegEncoder.cs` | **TASK 4 ship step 3 seam**: start/feed/stop the live encoder; `HealthUpdated` (parsed `StreamHealth`), `ProcessFailed` on unexpected non-zero exit. Constructor-injected locator + process factory (tests fake both) |
|
||||
| `Encoder/FfmpegEncoder.cs` | The default encoder: spawn `ffmpeg.exe` (probe `-encoders` first → hardware NVENC/QSV/AMF, OpenH264 fallback — never libx264), feed raw BGRA frames into stdin, parse `-stats` lines into `StreamHealth`, graceful stop via stdin EOF + 10s kill watchdog. Not yet constructed by the app (ship step 5 wires it) |
|
||||
| `Encoder/FfmpegEncoder.cs` | The default encoder: spawn `ffmpeg.exe` (probe `-encoders` first → hardware NVENC/QSV/AMF, OpenH264 fallback — never libx264), feed raw BGRA frames into stdin, parse `-stats` lines into `StreamHealth`, graceful stop via stdin EOF + 10s kill watchdog. Constructed by `MainViewModel` since ship step 5 (`new FfmpegEncoder(new FfmpegLocator())`), driven by the `FramePump` |
|
||||
| `Encoder/IEncoderProcess.cs` | Seam around the spawned subprocess (probe + encoder): redirected stdin/stdout/stderr + exit control — the encoder never touches `Process` directly |
|
||||
| `Encoder/FfmpegEncoderProcess.cs` | The real process wrapper (`Process` with all three std streams redirected) |
|
||||
| `Encoder/FfmpegArgs.cs` | Pure FFmpeg command-line builder: rawvideo `pipe:0` input, `anullsrc` silent audio (WASAPI replaces it), H.264+AAC, closed GOP (`-g fps×4 -keyint_min -sc_threshold 0 -bf 0`, `yuv420p`), FLV → RTMP |
|
||||
| `Encoder/FfmpegProgressParser.cs` | Pure parser for `frame=/fps=/size=/time=/bitrate=` stats lines → `FfmpegProgress` |
|
||||
| `Encoder/FfmpegEncoderPicker.cs` | Pure H.264 encoder picker from `-encoders` output: NVENC → QSV → AMF → OpenH264; **never returns libx264** (GPL) |
|
||||
| `Encoder/FfmpegLocator.cs` | The default locator: PATH probe wins, then the cache, then pull+extract (`ffmpeg.exe` **+ the `libav*.dll` family** via a staging dir, failures via `AppLog`). Pinned to BtbN `autobuild-2026-08-09-13-03` **lgpl-shared** (dynamic linking = LGPL compliance without static-relink §6 material; no GPL libx264; NVENC/QSV/AMF + libopenh264 + native AAC — see `ai.md` → Licensing). Search dirs / tools dir / downloader constructor-injected for hermetic tests. Not yet constructed by the app (the encoder step wires it) |
|
||||
| `Encoder/FramePump.cs` | **TASK 4 ship step 5**: the live frame producer — while live it snapshots the active scene each tick, resolves every element to its latest frame (`Func<SceneElement, VideoFrame?>` resolver), composites it into the tier's output frame, and paces frames into the encoder at the tier's FPS. All collaborators constructor-injected seams; free of WPF and the capture managers. `StartAsync` never throws (failures log + `Failed`); no RTMP URL = encoder skipped; `StopAsync` stops the encoder before awaiting the loop (backpressure deadlock); `ProcessFailed` self-stops. See `ai.md` "Live frame pipeline" |
|
||||
| `Encoder/FfmpegLocator.cs` | The default locator: PATH probe wins, then the cache, then pull+extract (`ffmpeg.exe` **+ the `libav*.dll` family** via a staging dir, failures via `AppLog`). Pinned to BtbN `autobuild-2026-08-09-13-03` **lgpl-shared** (dynamic linking = LGPL compliance without static-relink §6 material; no GPL libx264; NVENC/QSV/AMF + libopenh264 + native AAC — see `ai.md` → Licensing). Search dirs / tools dir / downloader constructor-injected for hermetic tests. Wired into the encoder since ship step 5 |
|
||||
| `Audio/IAudioSource.cs` | **TASK 4 ship step 4 seam**: live capture source — `Start`/`Stop`/`SampleReady(AudioSample)`/`Failed(Exception)`, `IDisposable`; runs only while live. The app consumes this seam; tests inject hermetic fakes |
|
||||
| `Audio/AudioSample.cs` | One captured chunk: interleaved PCM float (-1..1) + sample rate + channels |
|
||||
| `Audio/WasapiLoopbackAudioSource.cs` | Desktop/game capture: NAudio `WasapiLoopbackCapture` on the default render device — automatic at unity, zero UI |
|
||||
|
||||
Reference in New Issue
Block a user