teamsiso/src/TeamsISO.Engine/Pipeline/FrameProcessor.cs
2026-05-07 15:15:19 +00:00

100 lines
3.6 KiB
C#

using System.Threading.Channels;
using Microsoft.Extensions.Logging;
using TeamsISO.Engine.Domain;
namespace TeamsISO.Engine.Pipeline;
/// <summary>
/// Per-ISO frame timing engine. Implements closest-frame strategy: at each tick,
/// pick the newest available raw frame (dropping older queued frames), scale and emit it.
/// If no new frame is available, re-emit the last frame. If no frame has arrived for
/// <see cref="_slateThreshold"/>, emit a no-signal slate instead.
/// </summary>
public sealed class FrameProcessor
{
private readonly FrameProcessingSettings _settings;
private readonly IFrameScaler _scaler;
private readonly SolidFrameRenderer _slateRenderer;
private readonly IFrameClock _clock;
private readonly ChannelReader<RawFrame> _input;
private readonly ChannelWriter<ProcessedFrame> _output;
private readonly TimeSpan _slateThreshold;
private readonly ILogger<FrameProcessor> _logger;
private RawFrame? _lastRawFrame;
private long _lastFrameTickTicks;
private long _framesIn;
private long _framesOut;
private long _framesDropped;
private long _framesDuplicated;
private long _framesSlated;
public FrameProcessor(
FrameProcessingSettings settings,
IFrameScaler scaler,
SolidFrameRenderer slateRenderer,
IFrameClock clock,
ChannelReader<RawFrame> input,
ChannelWriter<ProcessedFrame> output,
TimeSpan slateThreshold,
ILogger<FrameProcessor> logger)
{
_settings = settings;
_scaler = scaler;
_slateRenderer = slateRenderer;
_clock = clock;
_input = input;
_output = output;
_slateThreshold = slateThreshold;
_logger = logger;
}
public IsoHealthStats Stats =>
new(
FramesIn: Interlocked.Read(ref _framesIn),
FramesOut: Interlocked.Read(ref _framesOut),
FramesDropped: Interlocked.Read(ref _framesDropped),
FramesDuplicated: Interlocked.Read(ref _framesDuplicated),
LastFrameAt: _lastFrameTickTicks == 0 ? null : new DateTimeOffset(_lastFrameTickTicks, TimeSpan.Zero),
IncomingFps: 0,
IncomingWidth: _lastRawFrame?.Width ?? 0,
IncomingHeight: _lastRawFrame?.Height ?? 0);
public Task ProcessOnceAsync(CancellationToken cancellationToken)
{
// Drain the input channel non-blockingly, keeping only the newest frame.
RawFrame? newest = null;
while (_input.TryRead(out var frame))
{
if (newest is not null)
Interlocked.Increment(ref _framesDropped);
newest = frame;
Interlocked.Increment(ref _framesIn);
}
var (targetW, targetH) = _settings.ResolutionSize;
var nowTicks = _clock.NowTicks;
ProcessedFrame toEmit;
if (newest is not null)
{
_lastRawFrame = newest;
_lastFrameTickTicks = nowTicks;
toEmit = _scaler.Scale(newest, targetW, targetH, _settings.Aspect, nowTicks);
}
else if (_lastRawFrame is not null && (nowTicks - _lastFrameTickTicks) <= _slateThreshold.Ticks)
{
Interlocked.Increment(ref _framesDuplicated);
toEmit = _scaler.Scale(_lastRawFrame, targetW, targetH, _settings.Aspect, nowTicks);
}
else
{
Interlocked.Increment(ref _framesSlated);
toEmit = _slateRenderer.Render(targetW, targetH, b: 0x80, g: 0x80, r: 0x80, a: 0xFF, nowTicks);
}
Interlocked.Increment(ref _framesOut);
_output.TryWrite(toEmit);
return Task.CompletedTask;
}
}