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>
|
<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" />
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue