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:
@@ -15,6 +15,8 @@ using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.Services.Audio;
|
||||
using ytLive.Services.Compositor;
|
||||
using ytLive.Services.Encoder;
|
||||
|
||||
namespace ytLive.ViewModels;
|
||||
|
||||
@@ -86,6 +88,12 @@ public class MainViewModel : ViewModelBase
|
||||
// encoder mix. Private by design — no UI beyond the existing mic controls.
|
||||
private readonly AudioMixer _audioMixer;
|
||||
|
||||
// Live frame pipeline (TASK 4 ship step 5): composites the active scene at the
|
||||
// tier's FPS and paces frames into the encoder. The RTMP URL seam stays null
|
||||
// until the live-stream create flow (TASK 5) supplies the reusable stream URL.
|
||||
private readonly Func<string?> _rtmpUrlProvider;
|
||||
private readonly FramePump _framePump;
|
||||
|
||||
// Screen backdrop: a permanent live capture (desktop/game) that every scene
|
||||
// shows at the bottom layer. One shared capture session per key — the
|
||||
// ScreenCaptureManager refcounts by key, mirroring CameraManager.
|
||||
@@ -857,6 +865,16 @@ public class MainViewModel : ViewModelBase
|
||||
_screenCaptureManager.CaptureFailed += (key, message) =>
|
||||
AppLog.Write($"ScreenCaptureManager: capture '{key}' failed: {message}");
|
||||
|
||||
_rtmpUrlProvider = () => null; // TASK 5: the reusable stream's ingest URL
|
||||
_framePump = new FramePump(
|
||||
sceneProvider: () => ActiveScene,
|
||||
frameResolver: ResolveOutputFrame,
|
||||
compositorOptions: BuildCompositorOptions,
|
||||
encoderOptions: BuildEncoderOptions,
|
||||
encoderFactory: () => new FfmpegEncoder(new FfmpegLocator()),
|
||||
log: message => AppLog.Write(message));
|
||||
_framePump.Failed += OnFramePumpFailed;
|
||||
|
||||
LoadLayout();
|
||||
_ = LoadSavedSessionAsync();
|
||||
AppLog.Write("MainViewModel ctor end");
|
||||
@@ -1169,6 +1187,7 @@ public class MainViewModel : ViewModelBase
|
||||
{
|
||||
_saveDebounce?.Stop();
|
||||
SaveLayoutNow();
|
||||
_framePump.Dispose();
|
||||
_cameraManager.Dispose();
|
||||
_screenCaptureManager.Dispose();
|
||||
_layoutStore.Dispose();
|
||||
@@ -1739,6 +1758,7 @@ public class MainViewModel : ViewModelBase
|
||||
: $"{dialog.StreamTitle} — ytLlive";
|
||||
StreamStatus = StreamStatus.Streaming;
|
||||
_audioMixer.Start();
|
||||
_ = _framePump.StartAsync(); // never throws; failures log + surface via Failed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1747,6 +1767,7 @@ public class MainViewModel : ViewModelBase
|
||||
StreamStatus = StreamStatus.Offline;
|
||||
WindowTitle = "ytLlive";
|
||||
_audioMixer.Stop();
|
||||
_ = _framePump.StopAsync();
|
||||
// Graceful end completes the session = signs out (the DPAPI token is
|
||||
// cleared so the next Start Stream requires a fresh sign-in). A crash
|
||||
// never runs this, so the token survives and the creator stays signed in.
|
||||
@@ -1768,6 +1789,59 @@ public class MainViewModel : ViewModelBase
|
||||
AudioLevel = level;
|
||||
}
|
||||
|
||||
// Scene-element → latest frame, for the live compositor. The map mirrors the
|
||||
// preview: webcam by DeviceId, live captures (the backdrop) by CaptureKey,
|
||||
// images/background by AssetId. A null frame leaves the element transparent.
|
||||
private VideoFrame? ResolveOutputFrame(SceneElement element)
|
||||
{
|
||||
return element switch
|
||||
{
|
||||
WebcamSceneConfig webcam => _cameraManager.GetLatestFrame(webcam.WebcamId),
|
||||
Source { IsLiveCapture: true, CaptureKey: not null } live => _screenCaptureManager.GetLatestFrame(live.CaptureKey),
|
||||
Source { AssetId: not null } image => StaticPixelCache.Get(image.AssetId),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
// The tier's crop rect over the 1920x1080 master, integer-aligned (the VM's
|
||||
// OutputRect* are doubles — the vertical 607.5 half-pixel crop rounds to 608).
|
||||
private CompositorOptions BuildCompositorOptions()
|
||||
{
|
||||
var quality = SelectedQuality;
|
||||
return new CompositorOptions
|
||||
{
|
||||
SourceRectX = (int)Math.Round(OutputRectX),
|
||||
SourceRectY = (int)Math.Round(OutputRectY),
|
||||
SourceRectWidth = (int)Math.Round(OutputRectWidth),
|
||||
SourceRectHeight = (int)Math.Round(OutputRectHeight),
|
||||
OutputWidth = quality.Width,
|
||||
OutputHeight = quality.Height,
|
||||
};
|
||||
}
|
||||
|
||||
// Full encoder options for the current tier, or null when no RTMP URL is
|
||||
// available — the pump then skips the encoder entirely (TASK 5 fills the seam).
|
||||
private EncoderOptions? BuildEncoderOptions()
|
||||
{
|
||||
var url = _rtmpUrlProvider();
|
||||
if (string.IsNullOrWhiteSpace(url)) return null;
|
||||
var quality = SelectedQuality;
|
||||
return new EncoderOptions
|
||||
{
|
||||
RtmpUrl = url,
|
||||
Width = quality.Width,
|
||||
Height = quality.Height,
|
||||
Fps = quality.Fps,
|
||||
BitrateKbps = (int)Math.Round(quality.Bitrate * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
private void OnFramePumpFailed(object? sender, string message)
|
||||
{
|
||||
AppLog.Write($"Frame pump failed: {message}");
|
||||
if (IsLive) StreamStatus = StreamStatus.Error;
|
||||
}
|
||||
|
||||
private void UpdateLiveVisuals()
|
||||
{
|
||||
var live = IsLive;
|
||||
|
||||
Reference in New Issue
Block a user