From 1d5d055b68ecd76df0b7cc4ed0089095bdf9a50c Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sun, 10 May 2026 21:08:40 -0400 Subject: [PATCH] =?UTF-8?q?Right-click=20=E2=86=92=20Save=20current=20fram?= =?UTF-8?q?e:=20snapshot=20ProcessedFrame=20as=20PNG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New context-menu action grabs the latest ProcessedFrame from IIsoController.GetLatestProcessedFrame and encodes it as a PNG under %USERPROFILE%\\Pictures\\TeamsISO\\. Filename includes participant display name + timestamp so back-to-back snapshots don't collide. Encoding path: WriteableBitmap(Bgra32) wraps the frame's pixel buffer verbatim (engine output is already top-down BGRA32), PngBitmapEncoder writes it. No re-encoding losses. Toast tells the operator where the file landed. Best-effort: if no frame is available yet (just-spun-up pipeline), warns rather than throws. Useful for highlight reels, social posts, attaching to bug reports. ParticipantViewModel gained an optional ToastViewModel constructor parameter so snapshot feedback surfaces in the existing toast. Wiring updated at the one call site in MainViewModel. --- src/TeamsISO.App/MainWindow.xaml | 3 + src/TeamsISO.App/ViewModels/MainViewModel.cs | 2 +- .../ViewModels/ParticipantViewModel.cs | 61 ++++++++++++++++++- 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/TeamsISO.App/MainWindow.xaml b/src/TeamsISO.App/MainWindow.xaml index 825c15f..aa16a8a 100644 --- a/src/TeamsISO.App/MainWindow.xaml +++ b/src/TeamsISO.App/MainWindow.xaml @@ -865,6 +865,9 @@ + diff --git a/src/TeamsISO.App/ViewModels/MainViewModel.cs b/src/TeamsISO.App/ViewModels/MainViewModel.cs index 6bd0b2e..fff6b22 100644 --- a/src/TeamsISO.App/ViewModels/MainViewModel.cs +++ b/src/TeamsISO.App/ViewModels/MainViewModel.cs @@ -813,7 +813,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable } else { - vm = new ParticipantViewModel(_controller, p); + vm = new ParticipantViewModel(_controller, p, Toast); _byId[p.Id] = vm; Participants.Add(vm); } diff --git a/src/TeamsISO.App/ViewModels/ParticipantViewModel.cs b/src/TeamsISO.App/ViewModels/ParticipantViewModel.cs index ce70d30..21d0833 100644 --- a/src/TeamsISO.App/ViewModels/ParticipantViewModel.cs +++ b/src/TeamsISO.App/ViewModels/ParticipantViewModel.cs @@ -15,6 +15,7 @@ namespace TeamsISO.App.ViewModels; public sealed class ParticipantViewModel : ObservableObject { private readonly IIsoController _controller; + private readonly ToastViewModel? _toast; private Participant _participant; private bool _isEnabled; private bool _isProcessing; @@ -43,9 +44,10 @@ public sealed class ParticipantViewModel : ObservableObject /// True when is non-null. Bound to Visibility in XAML. public bool HasThumbnail => _thumbnail is not null; - public ParticipantViewModel(IIsoController controller, Participant participant) + public ParticipantViewModel(IIsoController controller, Participant participant, ToastViewModel? toast = null) { _controller = controller; + _toast = toast; _participant = participant; _customName = string.Empty; ToggleIsoCommand = new AsyncRelayCommand(ToggleIsoAsync, () => !_isProcessing); @@ -75,6 +77,62 @@ public sealed class ParticipantViewModel : ObservableObject RestartIsoCommand = new AsyncRelayCommand(RestartIsoAsync, () => _isEnabled && !_isProcessing); + + SaveSnapshotCommand = new RelayCommand(SaveSnapshot, () => _isEnabled); + } + + /// + /// Encode the most recent + /// as a PNG and write it to %USERPROFILE%\Pictures\TeamsISO\. Used + /// by the participants' context menu for grabbing a stillframe — useful + /// for highlight reels, social posts, bug reports. Best-effort: a no-op + /// + warn-toast if no frame is currently available (pipeline just spun + /// up, or recording isn't enabled). Filename includes participant name + /// + timestamp so back-to-back snapshots don't collide. + /// + private void SaveSnapshot() + { + try + { + var frame = _controller.GetLatestProcessedFrame(Id); + if (frame is null || frame.Pixels.IsEmpty) + { + _toast?.Warn("No frame available yet — try again in a few seconds"); + return; + } + + var dir = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.MyPictures), + "TeamsISO"); + System.IO.Directory.CreateDirectory(dir); + + var safeName = string.Join("_", DisplayName.Split(System.IO.Path.GetInvalidFileNameChars())); + if (string.IsNullOrWhiteSpace(safeName)) safeName = "participant"; + var path = System.IO.Path.Combine(dir, + $"{safeName}_{DateTimeOffset.Now:yyyyMMdd_HHmmss}.png"); + + // ProcessedFrame is BGRA32, top-down. WriteableBitmap with + // Bgra32 pixel format takes the bytes verbatim. + var stride = frame.Width * 4; + var bmp = new System.Windows.Media.Imaging.WriteableBitmap( + frame.Width, frame.Height, + 96, 96, + System.Windows.Media.PixelFormats.Bgra32, null); + bmp.WritePixels( + new System.Windows.Int32Rect(0, 0, frame.Width, frame.Height), + frame.Pixels.ToArray(), stride, 0); + + using var fs = new System.IO.FileStream(path, System.IO.FileMode.Create); + var encoder = new System.Windows.Media.Imaging.PngBitmapEncoder(); + encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(bmp)); + encoder.Save(fs); + + _toast?.Show($"Saved snapshot: {System.IO.Path.GetFileName(path)}"); + } + catch (Exception ex) + { + _toast?.Warn($"Snapshot failed: {ex.Message}"); + } } /// @@ -358,6 +416,7 @@ public sealed class ParticipantViewModel : ObservableObject /// Open a non-modal floating preview window for this participant. Multi-monitor friendly. public RelayCommand OpenPreviewCommand { get; } + public RelayCommand SaveSnapshotCommand { get; } /// /// Restart the pipeline for this participant: disable, brief pause, re-enable.