feat: in-app preview thumbnails per participant
This commit is contained in:
parent
83224dbd9b
commit
e93b8caae0
3 changed files with 413 additions and 3 deletions
|
|
@ -4,10 +4,22 @@
|
|||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<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>
|
||||
<AssemblyName>TeamsISO</AssemblyName>
|
||||
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||
<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>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
@ -15,6 +27,18 @@
|
|||
<ProjectReference Include="..\TeamsISO.Engine.NdiInterop\TeamsISO.Engine.NdiInterop.csproj" />
|
||||
</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. -->
|
||||
<ItemGroup>
|
||||
<Resource Include="Assets\dragon-mark.png" />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using TeamsISO.Engine.Controller;
|
||||
using TeamsISO.Engine.Domain;
|
||||
using TeamsISO.Engine.Pipeline;
|
||||
using IsoHealthStats = TeamsISO.Engine.Domain.IsoHealthStats;
|
||||
|
||||
namespace TeamsISO.App.ViewModels;
|
||||
|
|
@ -16,12 +20,193 @@ public sealed class ParticipantViewModel : ObservableObject
|
|||
private bool _isProcessing;
|
||||
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)
|
||||
{
|
||||
_controller = controller;
|
||||
_participant = participant;
|
||||
_customName = string.Empty;
|
||||
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;
|
||||
|
|
@ -36,6 +221,20 @@ public sealed class ParticipantViewModel : ObservableObject
|
|||
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 _framesOut;
|
||||
private long _framesDropped;
|
||||
|
|
@ -53,6 +252,29 @@ public sealed class ParticipantViewModel : ObservableObject
|
|||
|
||||
private string _stateLabel = "—";
|
||||
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>
|
||||
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>
|
||||
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;
|
||||
FramesOut = stats.FramesOut;
|
||||
FramesDropped = stats.FramesDropped;
|
||||
|
|
@ -111,6 +353,19 @@ public sealed class ParticipantViewModel : ObservableObject
|
|||
|
||||
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>
|
||||
public void Update(Participant updated)
|
||||
{
|
||||
|
|
@ -133,9 +388,25 @@ public sealed class ParticipantViewModel : ObservableObject
|
|||
}
|
||||
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(
|
||||
Id,
|
||||
string.IsNullOrWhiteSpace(_customName) ? null : _customName,
|
||||
resolvedName,
|
||||
recordOverride,
|
||||
CancellationToken.None);
|
||||
IsEnabled = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.IO;
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TeamsISO.Engine.Domain;
|
||||
|
|
@ -35,6 +36,21 @@ public sealed class IsoPipeline : IAsyncDisposable
|
|||
private int _lastHeight;
|
||||
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.
|
||||
// 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
|
||||
|
|
@ -191,6 +207,7 @@ public sealed class IsoPipeline : IAsyncDisposable
|
|||
Volatile.Write(ref _liveReceiver, null);
|
||||
Volatile.Write(ref _liveSender, null);
|
||||
Volatile.Write(ref _liveProcessor, null);
|
||||
Volatile.Write(ref _latestProcessedFrame, null);
|
||||
ResetFrameTimestamps();
|
||||
},
|
||||
onFrame: frame =>
|
||||
|
|
@ -203,6 +220,13 @@ public sealed class IsoPipeline : IAsyncDisposable
|
|||
var nowTicks = DateTimeOffset.UtcNow.UtcTicks;
|
||||
_lastReceivedAt = new DateTimeOffset(nowTicks, TimeSpan.Zero);
|
||||
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,
|
||||
Action<NdiReceiver, NdiSender, FrameProcessor>? onLive = 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)
|
||||
{
|
||||
|
|
@ -323,6 +348,29 @@ public sealed class IsoPipeline : IAsyncDisposable
|
|||
? rawChannel.Writer
|
||||
: 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(
|
||||
interop, config.SourceName, rawWriter, loggerFactory.CreateLogger<NdiReceiver>());
|
||||
using var sender = new NdiSender(
|
||||
|
|
@ -331,7 +379,7 @@ public sealed class IsoPipeline : IAsyncDisposable
|
|||
|
||||
var processor = new FrameProcessor(
|
||||
config.Settings, scaler, new SolidFrameRenderer(),
|
||||
frameClock, rawChannel.Reader, processedChannel.Writer,
|
||||
frameClock, rawChannel.Reader, processedWriter,
|
||||
config.SlateThreshold, loggerFactory.CreateLogger<FrameProcessor>());
|
||||
|
||||
onLive?.Invoke(receiver, sender, processor);
|
||||
|
|
@ -348,6 +396,9 @@ public sealed class IsoPipeline : IAsyncDisposable
|
|||
{
|
||||
rawChannel.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();
|
||||
}
|
||||
}
|
||||
|
|
@ -372,6 +423,70 @@ public sealed class IsoPipeline : IAsyncDisposable
|
|||
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)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
|
|
|
|||
Loading…
Reference in a new issue