From e93b8caae00928c8fde0818babfdac73a79d5a9c Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sun, 10 May 2026 09:41:30 -0400 Subject: [PATCH] feat: in-app preview thumbnails per participant --- src/TeamsISO.App/TeamsISO.App.csproj | 24 ++ .../ViewModels/ParticipantViewModel.cs | 273 +++++++++++++++++- src/TeamsISO.Engine/Pipeline/IsoPipeline.cs | 119 +++++++- 3 files changed, 413 insertions(+), 3 deletions(-) diff --git a/src/TeamsISO.App/TeamsISO.App.csproj b/src/TeamsISO.App/TeamsISO.App.csproj index aa79754..bca05e1 100644 --- a/src/TeamsISO.App/TeamsISO.App.csproj +++ b/src/TeamsISO.App/TeamsISO.App.csproj @@ -4,10 +4,22 @@ WinExe net8.0-windows true + + true TeamsISO.App TeamsISO true Assets\teamsiso.ico + + true @@ -15,6 +27,18 @@ + + + + <_Parameter1>TeamsISO.App.Tests + + + diff --git a/src/TeamsISO.App/ViewModels/ParticipantViewModel.cs b/src/TeamsISO.App/ViewModels/ParticipantViewModel.cs index 7409dde..ce70d30 100644 --- a/src/TeamsISO.App/ViewModels/ParticipantViewModel.cs +++ b/src/TeamsISO.App/ViewModels/ParticipantViewModel.cs @@ -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; + /// Thumbnail dimensions. 16:9 ratio matches the typical Teams output aspect. + private const int ThumbnailWidth = 160; + private const int ThumbnailHeight = 90; + + /// + /// Live preview of the most recent processed frame, scaled to × + /// . Updated by on the UI + /// thread, called from MainViewModel's stats tick. Null until the first frame arrives. + /// + public WriteableBitmap? Thumbnail + { + get => _thumbnail; + private set + { + if (SetField(ref _thumbnail, value)) + OnPropertyChanged(nameof(HasThumbnail)); + } + } + private WriteableBitmap? _thumbnail; + + /// True when is non-null. Bound to Visibility in XAML. + 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); + } + + /// + /// 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. + /// + 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; + } + } + + /// + /// 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). + /// + 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(); + } + } + + /// + /// Inline nearest-neighbor scaler — copies one downsampled BGRA frame into the + /// WriteableBitmap's back buffer. We don't reuse + /// 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. + /// + private static void ScaleNearestNeighborBgra( + ReadOnlySpan 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 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); } + /// + /// 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. + /// + 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; + + /// + /// 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). + /// + public double DisplayedAudioLevel + { + get => _displayedAudioLevel; + private set => SetField(ref _displayedAudioLevel, value); + } + + /// + /// 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. + /// + public double AudioLevelWidthPercent => Math.Clamp(_displayedAudioLevel * 100, 0, 100); /// Human-readable pipeline state ("Receiving", "Error", "—"). public string StateLabel { get => _stateLabel; set => SetField(ref _stateLabel, value); } @@ -73,6 +295,26 @@ public sealed class ParticipantViewModel : ObservableObject /// Updates the live stats display from a controller-side snapshot. 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; } + /// Copy the participant's NDI source FullName to the clipboard. Useful for pasting into Studio Monitor. + public RelayCommand CopySourceNameCommand { get; } + + /// Open a non-modal floating preview window for this participant. Multi-monitor friendly. + public RelayCommand OpenPreviewCommand { get; } + + /// + /// 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. + /// + public AsyncRelayCommand RestartIsoCommand { get; } + /// Refreshes the underlying participant data (called when the controller emits an updated list). 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; } diff --git a/src/TeamsISO.Engine/Pipeline/IsoPipeline.cs b/src/TeamsISO.Engine/Pipeline/IsoPipeline.cs index a6f4c1a..b57ce6e 100644 --- a/src/TeamsISO.Engine/Pipeline/IsoPipeline.cs +++ b/src/TeamsISO.Engine/Pipeline/IsoPipeline.cs @@ -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 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; + + /// + /// The most recent 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. + /// + 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? onLive = null, Action? onClear = null, - Action? onFrame = null) + Action? onFrame = null, + Action? onProcessedFrame = null) { var rawChannel = Channel.CreateBounded(new BoundedChannelOptions(config.RawChannelCapacity) { @@ -323,6 +348,29 @@ public sealed class IsoPipeline : IAsyncDisposable ? rawChannel.Writer : new TappedChannelWriter(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 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(processedWriter, onProcessedFrame); + } + using var receiver = new NdiReceiver( interop, config.SourceName, rawWriter, loggerFactory.CreateLogger()); 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()); 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); } + /// + /// Channel-writer wrapper that feeds every successfully-written + /// to an . 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). + /// + private sealed class RecordingChannelWriter : ChannelWriter + { + private readonly ChannelWriter _inner; + private readonly IRecorderSink _recorder; + private readonly string _outputDirectory; + private readonly string _displayName; + private readonly double _fps; + private bool _opened; + + public RecordingChannelWriter( + ChannelWriter 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 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)