Right-click → Save current frame: snapshot ProcessedFrame as PNG
Some checks failed
CI / build-and-test (push) Failing after 29s
Some checks failed
CI / build-and-test (push) Failing after 29s
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.
This commit is contained in:
parent
acc569dd24
commit
1d5d055b68
3 changed files with 64 additions and 2 deletions
|
|
@ -865,6 +865,9 @@
|
||||||
<MenuItem Header="Open preview…"
|
<MenuItem Header="Open preview…"
|
||||||
Command="{Binding OpenPreviewCommand}"
|
Command="{Binding OpenPreviewCommand}"
|
||||||
ToolTip="Pop out a floating live-preview window. Drag to a second monitor for full-screen monitoring."/>
|
ToolTip="Pop out a floating live-preview window. Drag to a second monitor for full-screen monitoring."/>
|
||||||
|
<MenuItem Header="Save current frame…"
|
||||||
|
Command="{Binding SaveSnapshotCommand}"
|
||||||
|
ToolTip="Save the most recent processed frame as a PNG under %USERPROFILE%\Pictures\TeamsISO. Useful for highlight reels, social posts, or attaching to a bug report."/>
|
||||||
<MenuItem Header="Record this participant"
|
<MenuItem Header="Record this participant"
|
||||||
IsCheckable="True"
|
IsCheckable="True"
|
||||||
IsChecked="{Binding RecordToDisk}"/>
|
IsChecked="{Binding RecordToDisk}"/>
|
||||||
|
|
|
||||||
|
|
@ -813,7 +813,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
vm = new ParticipantViewModel(_controller, p);
|
vm = new ParticipantViewModel(_controller, p, Toast);
|
||||||
_byId[p.Id] = vm;
|
_byId[p.Id] = vm;
|
||||||
Participants.Add(vm);
|
Participants.Add(vm);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ namespace TeamsISO.App.ViewModels;
|
||||||
public sealed class ParticipantViewModel : ObservableObject
|
public sealed class ParticipantViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private readonly IIsoController _controller;
|
private readonly IIsoController _controller;
|
||||||
|
private readonly ToastViewModel? _toast;
|
||||||
private Participant _participant;
|
private Participant _participant;
|
||||||
private bool _isEnabled;
|
private bool _isEnabled;
|
||||||
private bool _isProcessing;
|
private bool _isProcessing;
|
||||||
|
|
@ -43,9 +44,10 @@ public sealed class ParticipantViewModel : ObservableObject
|
||||||
/// <summary>True when <see cref="Thumbnail"/> is non-null. Bound to Visibility in XAML.</summary>
|
/// <summary>True when <see cref="Thumbnail"/> is non-null. Bound to Visibility in XAML.</summary>
|
||||||
public bool HasThumbnail => _thumbnail is not null;
|
public bool HasThumbnail => _thumbnail is not null;
|
||||||
|
|
||||||
public ParticipantViewModel(IIsoController controller, Participant participant)
|
public ParticipantViewModel(IIsoController controller, Participant participant, ToastViewModel? toast = null)
|
||||||
{
|
{
|
||||||
_controller = controller;
|
_controller = controller;
|
||||||
|
_toast = toast;
|
||||||
_participant = participant;
|
_participant = participant;
|
||||||
_customName = string.Empty;
|
_customName = string.Empty;
|
||||||
ToggleIsoCommand = new AsyncRelayCommand(ToggleIsoAsync, () => !_isProcessing);
|
ToggleIsoCommand = new AsyncRelayCommand(ToggleIsoAsync, () => !_isProcessing);
|
||||||
|
|
@ -75,6 +77,62 @@ public sealed class ParticipantViewModel : ObservableObject
|
||||||
|
|
||||||
RestartIsoCommand = new AsyncRelayCommand(RestartIsoAsync,
|
RestartIsoCommand = new AsyncRelayCommand(RestartIsoAsync,
|
||||||
() => _isEnabled && !_isProcessing);
|
() => _isEnabled && !_isProcessing);
|
||||||
|
|
||||||
|
SaveSnapshotCommand = new RelayCommand(SaveSnapshot, () => _isEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encode the most recent <see cref="IsoPipeline.LatestProcessedFrame"/>
|
||||||
|
/// as a PNG and write it to <c>%USERPROFILE%\Pictures\TeamsISO\</c>. 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.
|
||||||
|
/// </summary>
|
||||||
|
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}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -358,6 +416,7 @@ public sealed class ParticipantViewModel : ObservableObject
|
||||||
|
|
||||||
/// <summary>Open a non-modal floating preview window for this participant. Multi-monitor friendly.</summary>
|
/// <summary>Open a non-modal floating preview window for this participant. Multi-monitor friendly.</summary>
|
||||||
public RelayCommand OpenPreviewCommand { get; }
|
public RelayCommand OpenPreviewCommand { get; }
|
||||||
|
public RelayCommand SaveSnapshotCommand { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Restart the pipeline for this participant: disable, brief pause, re-enable.
|
/// Restart the pipeline for this participant: disable, brief pause, re-enable.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue