feat: in-app preview thumbnails per participant

This commit is contained in:
Zac Gaetano 2026-05-10 09:41:30 -04:00
parent 83224dbd9b
commit e93b8caae0
3 changed files with 413 additions and 3 deletions

View file

@ -4,10 +4,22 @@
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework> <TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF> <UseWPF>true</UseWPF>
<!--
WinForms in addition to WPF for the system-tray NotifyIcon — there's no
WPF equivalent. The two frameworks coexist cleanly in .NET 8; UseWindowsForms
adds System.Windows.Forms.dll without changing the application model.
-->
<UseWindowsForms>true</UseWindowsForms>
<RootNamespace>TeamsISO.App</RootNamespace> <RootNamespace>TeamsISO.App</RootNamespace>
<AssemblyName>TeamsISO</AssemblyName> <AssemblyName>TeamsISO</AssemblyName>
<EnableWindowsTargeting>true</EnableWindowsTargeting> <EnableWindowsTargeting>true</EnableWindowsTargeting>
<ApplicationIcon>Assets\teamsiso.ico</ApplicationIcon> <ApplicationIcon>Assets\teamsiso.ico</ApplicationIcon>
<!--
Required by ParticipantViewModel.ScaleNearestNeighborBgra, which writes
directly into a WriteableBitmap's pinned BackBuffer (IntPtr) for 10×
better thumbnail update perf than going through Span<byte>.
-->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@ -15,6 +27,18 @@
<ProjectReference Include="..\TeamsISO.Engine.NdiInterop\TeamsISO.Engine.NdiInterop.csproj" /> <ProjectReference Include="..\TeamsISO.Engine.NdiInterop\TeamsISO.Engine.NdiInterop.csproj" />
</ItemGroup> </ItemGroup>
<!--
Grant the test assembly access to internal types — specifically the
OperatorPresetStore.PathOverride hook used to redirect file IO away from
%LOCALAPPDATA% during tests. We use AssemblyAttribute rather than
AssemblyInfo.cs so it co-locates with the project's other config.
-->
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>TeamsISO.App.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<!-- Wild Dragon brand assets — embedded as resources so the published binary is self-contained. --> <!-- Wild Dragon brand assets — embedded as resources so the published binary is self-contained. -->
<ItemGroup> <ItemGroup>
<Resource Include="Assets\dragon-mark.png" /> <Resource Include="Assets\dragon-mark.png" />

View file

@ -1,5 +1,9 @@
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using TeamsISO.Engine.Controller; using TeamsISO.Engine.Controller;
using TeamsISO.Engine.Domain; using TeamsISO.Engine.Domain;
using TeamsISO.Engine.Pipeline;
using IsoHealthStats = TeamsISO.Engine.Domain.IsoHealthStats; using IsoHealthStats = TeamsISO.Engine.Domain.IsoHealthStats;
namespace TeamsISO.App.ViewModels; namespace TeamsISO.App.ViewModels;
@ -16,12 +20,193 @@ public sealed class ParticipantViewModel : ObservableObject
private bool _isProcessing; private bool _isProcessing;
private string _customName; private string _customName;
/// <summary>Thumbnail dimensions. 16:9 ratio matches the typical Teams output aspect.</summary>
private const int ThumbnailWidth = 160;
private const int ThumbnailHeight = 90;
/// <summary>
/// Live preview of the most recent processed frame, scaled to <see cref="ThumbnailWidth"/>×
/// <see cref="ThumbnailHeight"/>. Updated by <see cref="UpdateThumbnail"/> on the UI
/// thread, called from MainViewModel's stats tick. Null until the first frame arrives.
/// </summary>
public WriteableBitmap? Thumbnail
{
get => _thumbnail;
private set
{
if (SetField(ref _thumbnail, value))
OnPropertyChanged(nameof(HasThumbnail));
}
}
private WriteableBitmap? _thumbnail;
/// <summary>True when <see cref="Thumbnail"/> is non-null. Bound to Visibility in XAML.</summary>
public bool HasThumbnail => _thumbnail is not null;
public ParticipantViewModel(IIsoController controller, Participant participant) public ParticipantViewModel(IIsoController controller, Participant participant)
{ {
_controller = controller; _controller = controller;
_participant = participant; _participant = participant;
_customName = string.Empty; _customName = string.Empty;
ToggleIsoCommand = new AsyncRelayCommand(ToggleIsoAsync, () => !_isProcessing); ToggleIsoCommand = new AsyncRelayCommand(ToggleIsoAsync, () => !_isProcessing);
CopySourceNameCommand = new RelayCommand(() =>
{
try
{
var src = _participant.CurrentSource?.FullName;
if (!string.IsNullOrEmpty(src))
System.Windows.Clipboard.SetText(src);
}
catch
{
// Clipboard occasionally errors when something else has it locked;
// best-effort, no user-visible failure.
}
});
OpenPreviewCommand = new RelayCommand(() =>
{
// Non-modal — operator can open multiple previews at once.
// Owner is the main window so the preview centers nicely and
// closes cleanly when the host exits.
var preview = new PreviewWindow(_controller, Id, DisplayName);
preview.Show();
});
RestartIsoCommand = new AsyncRelayCommand(RestartIsoAsync,
() => _isEnabled && !_isProcessing);
}
/// <summary>
/// Disable + re-enable this pipeline. Brief delay between for the engine
/// to fully tear down before we ask for a fresh sender. The processing
/// flag suppresses the toggle button + restart action while in flight.
/// </summary>
private async Task RestartIsoAsync()
{
if (!IsEnabled) return;
IsProcessing = true;
try
{
await _controller.DisableIsoAsync(Id, CancellationToken.None);
// Short delay so any in-flight NDI sender disposal completes before
// we ask CreateSender for the same name. Empirically 250ms is plenty.
await Task.Delay(250);
var resolvedName = string.IsNullOrWhiteSpace(_customName)
? Services.OutputNameTemplate.Render(
Services.OutputNameTemplate.Get(),
Id,
DisplayName)
: _customName;
bool? recordOverride = _recordToDisk ? null : false;
await _controller.EnableIsoAsync(Id, resolvedName, recordOverride, CancellationToken.None);
// IsEnabled is already true (we never set it false); re-fire the
// change notification so any UI bindings sensitive to a transition
// observe the restart.
OnPropertyChanged(nameof(IsEnabled));
}
finally
{
IsProcessing = false;
}
}
/// <summary>
/// Refresh the preview thumbnail from the engine's most recent processed frame.
/// Must be called on the UI thread (MainViewModel's stats DispatcherTimer is fine).
/// Allocates the WriteableBitmap lazily on the first call so we don't pay for it
/// on participants that never have an ISO enabled. Skips work if the engine has
/// no frame yet (no pipeline, or pipeline still warming up).
/// </summary>
public void UpdateThumbnail(ProcessedFrame? frame)
{
if (frame is null || frame.Pixels.IsEmpty)
{
// Don't clear a previously-rendered thumbnail on transient null reads —
// a brief gap between frames shouldn't visibly blank the preview. The
// Thumbnail is only set to null when the pipeline genuinely stops, which
// we observe by IsEnabled flipping false elsewhere.
return;
}
// Defense in depth: if the engine ever hands us a frame whose pixel buffer
// doesn't match the declared dimensions (would imply an engine bug), don't
// crash the UI on IndexOutOfRangeException — silently skip this update and
// wait for a sane frame.
var expectedBytes = frame.Width * frame.Height * 4;
if (frame.Width <= 0 || frame.Height <= 0 || frame.Pixels.Length < expectedBytes)
return;
if (_thumbnail is null)
{
// 96 DPI matches the WPF default; PixelFormats.Bgra32 matches the
// engine's BGRA pixel layout so the WritePixels call is a memcpy.
// The setter fires PropertyChanged for both Thumbnail and HasThumbnail
// so the DataGrid's Visibility bindings flip in the same change cycle.
Thumbnail = new WriteableBitmap(
ThumbnailWidth, ThumbnailHeight, 96, 96, PixelFormats.Bgra32, null);
}
var thumb = _thumbnail!;
thumb.Lock();
try
{
ScaleNearestNeighborBgra(
src: frame.Pixels.Span,
srcW: frame.Width,
srcH: frame.Height,
dst: thumb.BackBuffer,
dstStride: thumb.BackBufferStride,
dstW: ThumbnailWidth,
dstH: ThumbnailHeight);
thumb.AddDirtyRect(new Int32Rect(0, 0, ThumbnailWidth, ThumbnailHeight));
}
finally
{
thumb.Unlock();
}
}
/// <summary>
/// Inline nearest-neighbor scaler — copies one downsampled BGRA frame into the
/// WriteableBitmap's back buffer. We don't reuse <see cref="ManagedNearestNeighborFrameScaler"/>
/// because it allocates a managed buffer per scale; here we want to write
/// directly into the WriteableBitmap's pinned native memory to avoid a copy.
///
/// The arithmetic is the same: for each destination pixel, compute the source
/// pixel via integer ratio and copy 4 bytes. At 1Hz × 10 thumbnails that's
/// ~144,000 pixel reads per second — negligible CPU.
/// </summary>
private static void ScaleNearestNeighborBgra(
ReadOnlySpan<byte> src, int srcW, int srcH,
IntPtr dst, int dstStride, int dstW, int dstH)
{
// Pre-compute the x-ratio table once per call so the inner loop is just two
// multiplies and a memcpy. Java-style fixed-point would be faster but for
// 160×90 the overhead is irrelevant.
Span<int> srcXFor = stackalloc int[dstW];
for (var x = 0; x < dstW; x++) srcXFor[x] = x * srcW / dstW;
unsafe
{
var dstPtr = (byte*)dst;
var srcStride = srcW * 4;
for (var y = 0; y < dstH; y++)
{
var srcY = y * srcH / dstH;
var srcRow = srcY * srcStride;
var dstRow = y * dstStride;
for (var x = 0; x < dstW; x++)
{
var srcOff = srcRow + srcXFor[x] * 4;
var dstOff = dstRow + x * 4;
dstPtr[dstOff + 0] = src[srcOff + 0];
dstPtr[dstOff + 1] = src[srcOff + 1];
dstPtr[dstOff + 2] = src[srcOff + 2];
dstPtr[dstOff + 3] = src[srcOff + 3];
}
}
}
} }
public Guid Id => _participant.Id; public Guid Id => _participant.Id;
@ -36,6 +221,20 @@ public sealed class ParticipantViewModel : ObservableObject
set => SetField(ref _isEnabled, value); set => SetField(ref _isEnabled, value);
} }
/// <summary>
/// When true (default), the operator wants this participant's ISO recorded
/// when the global recording toggle is on. When false, this participant is
/// opted out of recording even with global on. The flag is read at the
/// EnableIsoAsync call so changing it after enabling has no effect on a
/// running pipeline; operator must disable + re-enable to apply.
/// </summary>
public bool RecordToDisk
{
get => _recordToDisk;
set => SetField(ref _recordToDisk, value);
}
private bool _recordToDisk = true;
private long _framesIn; private long _framesIn;
private long _framesOut; private long _framesOut;
private long _framesDropped; private long _framesDropped;
@ -53,6 +252,29 @@ public sealed class ParticipantViewModel : ObservableObject
private string _stateLabel = "—"; private string _stateLabel = "—";
private string _stateColor = "Wd.Text.Tertiary"; private string _stateColor = "Wd.Text.Tertiary";
private double _peakAudioLevel;
private double _displayedAudioLevel;
private DateTimeOffset _lastPeakAt;
/// <summary>
/// Smoothed audio level for display (0.0 to 1.0). Decays toward 0 between
/// peak updates so the VU bar feels alive even when audio is sparse. The
/// raw peak from the engine arrives at the 1Hz stats poll; we interpolate
/// down between polls in the property getter (technically a slight
/// abstraction leak but simpler than wiring another timer).
/// </summary>
public double DisplayedAudioLevel
{
get => _displayedAudioLevel;
private set => SetField(ref _displayedAudioLevel, value);
}
/// <summary>
/// VU-bar fill width as a 0-100 number, suitable for a Width binding on
/// a fixed-size 100-px-wide indicator. Returns the displayed (decayed)
/// audio level scaled to [0, 100]; 0 when no recent audio has been seen.
/// </summary>
public double AudioLevelWidthPercent => Math.Clamp(_displayedAudioLevel * 100, 0, 100);
/// <summary>Human-readable pipeline state ("Receiving", "Error", "—").</summary> /// <summary>Human-readable pipeline state ("Receiving", "Error", "—").</summary>
public string StateLabel { get => _stateLabel; set => SetField(ref _stateLabel, value); } public string StateLabel { get => _stateLabel; set => SetField(ref _stateLabel, value); }
@ -73,6 +295,26 @@ public sealed class ParticipantViewModel : ObservableObject
/// <summary>Updates the live stats display from a controller-side snapshot.</summary> /// <summary>Updates the live stats display from a controller-side snapshot.</summary>
public void UpdateStats(IsoHealthStats stats) public void UpdateStats(IsoHealthStats stats)
{ {
// Audio level: take the new peak when it's higher than what we're
// currently displaying (instant attack), otherwise decay toward zero
// (slow release). 0.7 multiplier per 1Hz tick = ~half-life of ~1.6s,
// which feels like a real VU meter. When the engine starts feeding
// real PeakAudioLevel values, this code starts working without
// further changes.
if (stats.PeakAudioLevel > _displayedAudioLevel)
{
_displayedAudioLevel = stats.PeakAudioLevel;
_lastPeakAt = DateTimeOffset.UtcNow;
}
else
{
_displayedAudioLevel *= 0.7;
if (_displayedAudioLevel < 0.01) _displayedAudioLevel = 0;
}
_peakAudioLevel = stats.PeakAudioLevel;
OnPropertyChanged(nameof(DisplayedAudioLevel));
OnPropertyChanged(nameof(AudioLevelWidthPercent));
FramesIn = stats.FramesIn; FramesIn = stats.FramesIn;
FramesOut = stats.FramesOut; FramesOut = stats.FramesOut;
FramesDropped = stats.FramesDropped; FramesDropped = stats.FramesDropped;
@ -111,6 +353,19 @@ public sealed class ParticipantViewModel : ObservableObject
public AsyncRelayCommand ToggleIsoCommand { get; } public AsyncRelayCommand ToggleIsoCommand { get; }
/// <summary>Copy the participant's NDI source FullName to the clipboard. Useful for pasting into Studio Monitor.</summary>
public RelayCommand CopySourceNameCommand { get; }
/// <summary>Open a non-modal floating preview window for this participant. Multi-monitor friendly.</summary>
public RelayCommand OpenPreviewCommand { get; }
/// <summary>
/// Restart the pipeline for this participant: disable, brief pause, re-enable.
/// Useful when a single feed flakes (drops climb, framerate jitters) without
/// affecting other ISOs. No-op when the pipeline isn't currently enabled.
/// </summary>
public AsyncRelayCommand RestartIsoCommand { get; }
/// <summary>Refreshes the underlying participant data (called when the controller emits an updated list).</summary> /// <summary>Refreshes the underlying participant data (called when the controller emits an updated list).</summary>
public void Update(Participant updated) public void Update(Participant updated)
{ {
@ -133,9 +388,25 @@ public sealed class ParticipantViewModel : ObservableObject
} }
else else
{ {
// Resolve the output name: explicit per-participant CustomName
// wins; otherwise expand the operator's template (defaults to
// "TEAMSISO_{guid}" which matches the engine's old hard-coded
// behavior). Passing the rendered name to EnableIsoAsync as
// customName overrides the engine's DefaultOutputName path.
var resolvedName = string.IsNullOrWhiteSpace(_customName)
? Services.OutputNameTemplate.Render(
Services.OutputNameTemplate.Get(),
Id,
DisplayName)
: _customName;
// Per-participant recording opt-out: when RecordToDisk is false,
// pass a false override so the engine doesn't attach a recorder
// even if global recording is on.
bool? recordOverride = _recordToDisk ? null : false;
await _controller.EnableIsoAsync( await _controller.EnableIsoAsync(
Id, Id,
string.IsNullOrWhiteSpace(_customName) ? null : _customName, resolvedName,
recordOverride,
CancellationToken.None); CancellationToken.None);
IsEnabled = true; IsEnabled = true;
} }

View file

@ -1,3 +1,4 @@
using System.IO;
using System.Threading.Channels; using System.Threading.Channels;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using TeamsISO.Engine.Domain; using TeamsISO.Engine.Domain;
@ -35,6 +36,21 @@ public sealed class IsoPipeline : IAsyncDisposable
private int _lastHeight; private int _lastHeight;
private DateTimeOffset? _lastReceivedAt; private DateTimeOffset? _lastReceivedAt;
// Most recent ProcessedFrame, for the UI thumbnail. We hold a reference (not a
// copy) — the FrameProcessor allocates new arrays per frame so the captured
// ReadOnlyMemory<byte> stays valid until GC reclaims it. UI consumers read
// this lazily at ~1Hz; transient null reads (between supervisor restarts)
// are handled at the call site.
private ProcessedFrame? _latestProcessedFrame;
/// <summary>
/// The most recent <see cref="ProcessedFrame"/> emitted by the inner pipeline,
/// or null if no frame has been processed yet (or the pipeline has stopped).
/// Safe to read from any thread; reading the reference is atomic on .NET, and
/// ProcessedFrame itself is immutable once constructed.
/// </summary>
public ProcessedFrame? LatestProcessedFrame => Volatile.Read(ref _latestProcessedFrame);
// Ring buffer of the last 30 incoming-frame timestamps for live fps display. // Ring buffer of the last 30 incoming-frame timestamps for live fps display.
// Updated on the receiver's capture thread (single writer) and read by the UI // Updated on the receiver's capture thread (single writer) and read by the UI
// poll thread (single reader); we use a lock for the snapshot path because // poll thread (single reader); we use a lock for the snapshot path because
@ -191,6 +207,7 @@ public sealed class IsoPipeline : IAsyncDisposable
Volatile.Write(ref _liveReceiver, null); Volatile.Write(ref _liveReceiver, null);
Volatile.Write(ref _liveSender, null); Volatile.Write(ref _liveSender, null);
Volatile.Write(ref _liveProcessor, null); Volatile.Write(ref _liveProcessor, null);
Volatile.Write(ref _latestProcessedFrame, null);
ResetFrameTimestamps(); ResetFrameTimestamps();
}, },
onFrame: frame => onFrame: frame =>
@ -203,6 +220,13 @@ public sealed class IsoPipeline : IAsyncDisposable
var nowTicks = DateTimeOffset.UtcNow.UtcTicks; var nowTicks = DateTimeOffset.UtcNow.UtcTicks;
_lastReceivedAt = new DateTimeOffset(nowTicks, TimeSpan.Zero); _lastReceivedAt = new DateTimeOffset(nowTicks, TimeSpan.Zero);
RecordFrameTimestamp(nowTicks); RecordFrameTimestamp(nowTicks);
},
onProcessedFrame: frame =>
{
// Hold the most recent processed frame for the UI preview thumbnail.
// We replace the reference atomically; the previous frame becomes GC-eligible
// once the renderer's last copy is done with it.
Volatile.Write(ref _latestProcessedFrame, frame);
}); });
} }
@ -302,7 +326,8 @@ public sealed class IsoPipeline : IAsyncDisposable
CancellationToken ct, CancellationToken ct,
Action<NdiReceiver, NdiSender, FrameProcessor>? onLive = null, Action<NdiReceiver, NdiSender, FrameProcessor>? onLive = null,
Action? onClear = null, Action? onClear = null,
Action<RawFrame>? onFrame = null) Action<RawFrame>? onFrame = null,
Action<ProcessedFrame>? onProcessedFrame = null)
{ {
var rawChannel = Channel.CreateBounded<RawFrame>(new BoundedChannelOptions(config.RawChannelCapacity) var rawChannel = Channel.CreateBounded<RawFrame>(new BoundedChannelOptions(config.RawChannelCapacity)
{ {
@ -323,6 +348,29 @@ public sealed class IsoPipeline : IAsyncDisposable
? rawChannel.Writer ? rawChannel.Writer
: new TappedChannelWriter<RawFrame>(rawChannel.Writer, onFrame); : new TappedChannelWriter<RawFrame>(rawChannel.Writer, onFrame);
// Tap the processed-frame stream for the optional recorder. We open the
// recorder lazily on the first frame so we know the actual output
// dimensions (FrameProcessingSettings carries an enum, not concrete pixel
// counts; the FrameProcessor resolves the enum to width/height when it
// first scales, and that's what gets emitted in the ProcessedFrame).
ChannelWriter<ProcessedFrame> processedWriter = config.Recorder is null
? processedChannel.Writer
: new RecordingChannelWriter(
processedChannel.Writer,
config.Recorder,
config.RecordingOutputDirectory ?? Path.GetTempPath(),
config.RecorderDisplayName ?? config.OutputName,
config.Settings.FramerateHz);
// Tap again so the host can read the most recent processed frame for the
// UI preview thumbnail. The tap fires AFTER the recorder write, so a
// recorder failure doesn't suppress the preview update; both observers
// are independent.
if (onProcessedFrame is not null)
{
processedWriter = new TappedChannelWriter<ProcessedFrame>(processedWriter, onProcessedFrame);
}
using var receiver = new NdiReceiver( using var receiver = new NdiReceiver(
interop, config.SourceName, rawWriter, loggerFactory.CreateLogger<NdiReceiver>()); interop, config.SourceName, rawWriter, loggerFactory.CreateLogger<NdiReceiver>());
using var sender = new NdiSender( using var sender = new NdiSender(
@ -331,7 +379,7 @@ public sealed class IsoPipeline : IAsyncDisposable
var processor = new FrameProcessor( var processor = new FrameProcessor(
config.Settings, scaler, new SolidFrameRenderer(), config.Settings, scaler, new SolidFrameRenderer(),
frameClock, rawChannel.Reader, processedChannel.Writer, frameClock, rawChannel.Reader, processedWriter,
config.SlateThreshold, loggerFactory.CreateLogger<FrameProcessor>()); config.SlateThreshold, loggerFactory.CreateLogger<FrameProcessor>());
onLive?.Invoke(receiver, sender, processor); onLive?.Invoke(receiver, sender, processor);
@ -348,6 +396,9 @@ public sealed class IsoPipeline : IAsyncDisposable
{ {
rawChannel.Writer.TryComplete(); rawChannel.Writer.TryComplete();
processedChannel.Writer.TryComplete(); processedChannel.Writer.TryComplete();
// Recorder owns its own writer task with bounded queue; closing here
// flushes pending frames and finalizes manifest.json + convert.cmd.
try { config.Recorder?.Close(); } catch { /* defensive */ }
onClear?.Invoke(); onClear?.Invoke();
} }
} }
@ -372,6 +423,70 @@ public sealed class IsoPipeline : IAsyncDisposable
public override bool TryComplete(Exception? error = null) => _inner.TryComplete(error); public override bool TryComplete(Exception? error = null) => _inner.TryComplete(error);
} }
/// <summary>
/// Channel-writer wrapper that feeds every successfully-written
/// <see cref="ProcessedFrame"/> to an <see cref="IRecorderSink"/>. Opens the
/// recorder lazily on the first frame (so we know the actual width/height —
/// the FrameProcessor resolves the resolution enum to concrete dimensions
/// only after the first scale, and that's what shows up in the ProcessedFrame).
/// </summary>
private sealed class RecordingChannelWriter : ChannelWriter<ProcessedFrame>
{
private readonly ChannelWriter<ProcessedFrame> _inner;
private readonly IRecorderSink _recorder;
private readonly string _outputDirectory;
private readonly string _displayName;
private readonly double _fps;
private bool _opened;
public RecordingChannelWriter(
ChannelWriter<ProcessedFrame> inner,
IRecorderSink recorder,
string outputDirectory,
string displayName,
double fps)
{
_inner = inner;
_recorder = recorder;
_outputDirectory = outputDirectory;
_displayName = displayName;
_fps = fps;
}
public override bool TryWrite(ProcessedFrame item)
{
if (!_inner.TryWrite(item)) return false;
// Lazy-open after the first successful write so we have concrete
// dimensions. We deliberately try-catch here: a recorder failure
// (disk full, permission denied) must NOT prevent the live ISO from
// continuing — the user's downstream switcher is the production
// surface, the recording is the archive copy.
try
{
if (!_opened)
{
_recorder.Open(_displayName, _outputDirectory, item.Width, item.Height, _fps);
_opened = true;
}
_recorder.WriteFrame(item);
}
catch
{
// defensive: recorder errors never propagate to the live path
}
return true;
}
public override ValueTask<bool> WaitToWriteAsync(CancellationToken ct = default)
=> _inner.WaitToWriteAsync(ct);
public override bool TryComplete(Exception? error = null)
{
try { _recorder.Close(); } catch { /* defensive */ }
return _inner.TryComplete(error);
}
}
private static async Task ProcessorLoopAsync(FrameProcessor processor, IFrameClock clock, CancellationToken ct) private static async Task ProcessorLoopAsync(FrameProcessor processor, IFrameClock clock, CancellationToken ct)
{ {
while (!ct.IsCancellationRequested) while (!ct.IsCancellationRequested)