From 5a43c9cb6a0e942ba8dc5d4ddbf83a7004513824 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Fri, 15 May 2026 15:31:32 -0400 Subject: [PATCH] feat: per-ISO framerate/resolution/aspect/audio overrides + thumbnail BMP Engine: IsoAssignment record gets optional Override (FrameProcessingSettings?). IsoController hydrates _overrides dict from config.json on startup, uses override at EnableIsoAsync, persists with assignment, exposes GetIsoOverride + SetIsoOverrideAsync. SetIsoOverrideAsync hot-swaps a running pipeline (Disable + 150ms delay + Enable) when the override changes. REST: POST /participants/{id}/override (body: framerate/resolution/aspect/audio enum strings, all optional, missing fall back to globals); DELETE /participants/{id}/override clears. GET /participants now includes per-row effective {framerate, resolution, aspect, audio, isOverride} plus top-level globals block. Web /ui: per-card collapsible override panel with four selects + Apply / Clear. OVR pill + cyan inset edge mark overridden rows. Open-panel state survives WS re-renders. Desktop: per-row gear column in the v2 DataGrid opens IsoOverrideDialog (420x360) with four combos. Clear button removes the override. Thumbnail endpoint switched from WPF JpegBitmapEncoder (NREs from non-UI HttpListener threads) to pure-managed 32bpp BMP encoder. Nearest-neighbor downscale to 192-wide. /participants/{id}/thumbnail.bmp; legacy .jpg URL still works. Known limitation: ParticipantTracker regenerates IDs for display-name-keyed participants across process restarts, orphaning the persisted override. Override works within a session; cross-restart persistence is best-effort until the tracker is taught to use stable keys. Filed as task 43. --- src/TeamsISO.App/MainWindow.xaml | 23 ++ src/TeamsISO.App/MainWindow.xaml.cs | 25 ++ src/TeamsISO.App/Services/ControlPanelHtml.cs | 161 ++++++++++- .../Services/ControlSurfaceServer.cs | 261 ++++++++++++++++-- .../ViewModels/IsoOverrideDialogViewModel.cs | 135 +++++++++ src/TeamsISO.App/Views/IsoOverrideDialog.xaml | 173 ++++++++++++ .../Views/IsoOverrideDialog.xaml.cs | 30 ++ .../Controller/IIsoController.cs | 20 ++ .../Controller/IsoController.cs | 90 +++++- src/TeamsISO.Engine/Domain/IsoAssignment.cs | 10 +- 10 files changed, 890 insertions(+), 38 deletions(-) create mode 100644 src/TeamsISO.App/ViewModels/IsoOverrideDialogViewModel.cs create mode 100644 src/TeamsISO.App/Views/IsoOverrideDialog.xaml create mode 100644 src/TeamsISO.App/Views/IsoOverrideDialog.xaml.cs diff --git a/src/TeamsISO.App/MainWindow.xaml b/src/TeamsISO.App/MainWindow.xaml index 314823f..fbd0ca4 100644 --- a/src/TeamsISO.App/MainWindow.xaml +++ b/src/TeamsISO.App/MainWindow.xaml @@ -614,6 +614,29 @@ + + + + + + + + + diff --git a/src/TeamsISO.App/MainWindow.xaml.cs b/src/TeamsISO.App/MainWindow.xaml.cs index ce569b1..5d68c6a 100644 --- a/src/TeamsISO.App/MainWindow.xaml.cs +++ b/src/TeamsISO.App/MainWindow.xaml.cs @@ -236,6 +236,31 @@ public partial class MainWindow : Window palette.ShowDialog(); } + /// + /// Open the per-participant ISO override editor. Bound to the gear button + /// in the participant row. The dialog reads the engine's current override + /// (if any) and lets the operator edit framerate / resolution / aspect / + /// audio for that specific pipeline; Apply / Clear / Cancel are handled by + /// the dialog's view-model, so this handler is just plumbing. + /// + private void OnIsoOverrideClick(object sender, RoutedEventArgs e) + { + if (DataContext is not MainViewModel vm) return; + if (sender is not FrameworkElement fe) return; + if (fe.DataContext is not ParticipantViewModel p) return; + + var currentOverride = vm.Controller.GetIsoOverride(p.Id); + var dialogVm = new ViewModels.IsoOverrideDialogViewModel( + vm.Controller, + vm.Settings, + p.Id, + p.DisplayName, + currentOverride, + vm.Toast); + var dialog = new Views.IsoOverrideDialog(dialogVm) { Owner = this }; + dialog.ShowDialog(); + } + /// /// Esc closes the drawer when it's open. We use PreviewKeyDown rather than /// KeyDown so the drawer's nested inputs (TextBox, ComboBox) don't swallow diff --git a/src/TeamsISO.App/Services/ControlPanelHtml.cs b/src/TeamsISO.App/Services/ControlPanelHtml.cs index a7aa9a3..64a0d2e 100644 --- a/src/TeamsISO.App/Services/ControlPanelHtml.cs +++ b/src/TeamsISO.App/Services/ControlPanelHtml.cs @@ -92,8 +92,10 @@ internal static class ControlPanelHtml .empty { color: var(--text-3); font-size: 12px; padding: 18px; text-align: center; } .global-actions { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; } @media (min-width: 480px) { .global-actions { grid-template-columns: repeat(4, 1fr); } } + .participant-wrap { border-radius: 10px; } + .participant-wrap + .participant-wrap { margin-top: 6px; } + .participant-wrap.override { box-shadow: inset 3px 0 0 var(--cyan); } .participant-row { display: flex; align-items: center; gap: 14px; padding: 10px; border-radius: 10px; } - .participant-row + .participant-row { margin-top: 6px; } .participant-row.speaking { background: var(--cyan-mute); } .preview { width: 112px; height: 63px; flex-shrink: 0; border-radius: 6px; @@ -102,6 +104,35 @@ internal static class ControlPanelHtml } .preview.empty { display: flex; align-items: center; justify-content: center; color: var(--text-3); font-family: 'JetBrains Mono', monospace; font-size: 18px; } + .ovr-pill { display: inline-block; margin-left: 6px; padding: 1px 6px; + border-radius: 999px; background: var(--cyan-mute); color: var(--cyan-text); + font-size: 9px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; + vertical-align: middle; } + .cfg-caption { font-family: 'JetBrains Mono', 'Cascadia Mono', monospace; + font-size: 10px; color: var(--text-3); margin-right: 6px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + max-width: 140px; } + .gear-btn { padding: 6px 10px; font-size: 12px; } + .row-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } + .override-panel { display: none; padding: 12px 10px 14px; + border-top: 1px solid var(--border); background: var(--surface-elev); + border-bottom-left-radius: 10px; border-bottom-right-radius: 10px; } + .override-panel.open { display: block; } + .override-grid { display: grid; grid-template-columns: 1fr; gap: 10px; } + @media (min-width: 560px) { + .override-grid { grid-template-columns: repeat(4, 1fr); } + } + .override-field { display: flex; flex-direction: column; gap: 4px; min-width: 0; } + .override-field label { font-size: 10px; font-weight: 600; letter-spacing: 0.08em; + color: var(--text-3); text-transform: uppercase; } + .override-field select { + background: var(--surface); color: var(--text); + border: 1px solid var(--border); border-radius: 8px; + padding: 8px 10px; font: inherit; font-size: 12px; + appearance: none; -webkit-appearance: none; + } + .override-field select:focus { outline: none; border-color: var(--cyan); } + .override-actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; } .topology-card { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; } .topology-state { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 200px; } .topology-state strong { font-size: 13px; color: var(--text); } @@ -226,6 +257,51 @@ async function restoreTopology() { fetchTopology(); } +// Enum options for the per-participant override selects. Values match the +// .NET enum names so they round-trip through POST /participants/{id}/override +// without translation. +const FRAMERATE_OPTS = [ + ['Fps23_976', '23.976'], ['Fps24', '24'], ['Fps25', '25'], + ['Fps29_97', '29.97'], ['Fps30', '30'], ['Fps50', '50'], + ['Fps59_94', '59.94'], ['Fps60', '60'], +]; +const RESOLUTION_OPTS = [ + ['R720p', '720p'], ['R1080p', '1080p'], ['R4K', '4K'], +]; +const ASPECT_OPTS = [ + ['Pillarbox', 'Pillarbox'], ['Letterbox', 'Letterbox'], ['Stretch', 'Stretch'], +]; +const AUDIO_OPTS = [ + ['Auto', 'Auto'], ['Isolated', 'Isolated'], ['Mixed', 'Mixed'], +]; + +// Track which participant rows have the override panel expanded so it +// survives re-renders driven by the WS state push (otherwise every +// 1Hz snapshot would collapse it under the operator's finger). +const openPanels = new Set(); + +function shortFps(v) { + for (const [k, label] of FRAMERATE_OPTS) if (k === v) return label; + return v || '—'; +} +function shortRes(v) { + for (const [k, label] of RESOLUTION_OPTS) if (k === v) return label; + return v || '—'; +} +function shortAudio(v) { + for (const [k, label] of AUDIO_OPTS) if (k === v) return label; + return v || '—'; +} + +function buildSelect(opts, current) { + let html = ''; + for (const [val, label] of opts) { + const sel = (val === current) ? ' selected' : ''; + html += """"; + } + return html; +} + function render(participants) { if (!participants || participants.length === 0) { list.innerHTML = ""
No participants visible. Open Teams and join a meeting; sources will populate within seconds.
""; @@ -238,6 +314,12 @@ function render(participants) { const card = document.createElement('div'); card.className = 'card'; for (const p of participants) { + const wrap = document.createElement('div'); + wrap.className = 'participant-wrap'; + const eff = p.effective || {}; + const isOverride = !!eff.isOverride; + if (isOverride) wrap.classList.add('override'); + const row = document.createElement('div'); row.className = 'participant-row'; const stateColor = p.isEnabled ? 'cyan' : (p.isOnline ? 'gray' : 'coral'); @@ -253,20 +335,85 @@ function render(participants) { ""
"" + ""
"" + """" + - """"; + ""
"" + + """" + + """" + + """" + + ""
""; const img = row.querySelector('img.preview'); img.src = previewUrl; row.querySelector('.name').textContent = p.displayName; - row.querySelector('.sub').textContent = + const subEl = row.querySelector('.sub'); + subEl.textContent = (p.stateLabel || (p.isOnline ? 'online' : 'offline')) + (p.customName ? ' · ' + p.customName : ''); - const btn = row.querySelector('button'); - btn.textContent = p.isEnabled ? '● LIVE' : 'Enable'; - btn.onclick = () => post('/participants/iso', { + if (isOverride) { + const pill = document.createElement('span'); + pill.className = 'ovr-pill'; + pill.textContent = 'OVR'; + subEl.appendChild(pill); + } + row.querySelector('.cfg-caption').textContent = + shortFps(eff.framerate) + ' · ' + shortRes(eff.resolution) + ' · ' + shortAudio(eff.audio); + const enableBtn = row.querySelector('.enable-btn'); + enableBtn.className = 'enable-btn ' + (p.isEnabled ? 'live' : ''); + enableBtn.textContent = p.isEnabled ? '● LIVE' : 'Enable'; + enableBtn.onclick = () => post('/participants/iso', { displayName: p.displayName, enabled: !p.isEnabled, }); - card.appendChild(row); + + const panel = document.createElement('div'); + panel.className = 'override-panel' + (openPanels.has(p.id) ? ' open' : ''); + panel.innerHTML = + ""
"" + + ""
"" + + ""
"" + + ""
"" + + ""
"" + + ""
"" + + ""
"" + + ""
"" + + ""
"" + + ""
"" + + ""
"" + + """" + + """" + + ""
""; + + const gearBtn = row.querySelector('.gear-btn'); + gearBtn.onclick = () => { + if (openPanels.has(p.id)) { + openPanels.delete(p.id); + panel.classList.remove('open'); + } else { + openPanels.add(p.id); + panel.classList.add('open'); + } + }; + + panel.querySelector('.apply-btn').onclick = async () => { + const body = {}; + panel.querySelectorAll('select[data-k]').forEach(s => { body[s.dataset.k] = s.value; }); + await fetch('/participants/' + p.id + '/override', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }).catch(e => console.warn(e)); + openPanels.delete(p.id); + panel.classList.remove('open'); + }; + + panel.querySelector('.clear-btn').onclick = async () => { + await fetch('/participants/' + p.id + '/override', { method: 'DELETE' }) + .catch(e => console.warn(e)); + openPanels.delete(p.id); + panel.classList.remove('open'); + }; + + wrap.appendChild(row); + wrap.appendChild(panel); + card.appendChild(wrap); } list.appendChild(card); } diff --git a/src/TeamsISO.App/Services/ControlSurfaceServer.cs b/src/TeamsISO.App/Services/ControlSurfaceServer.cs index 658e938..48051ce 100644 --- a/src/TeamsISO.App/Services/ControlSurfaceServer.cs +++ b/src/TeamsISO.App/Services/ControlSurfaceServer.cs @@ -10,6 +10,7 @@ using System.Windows.Threading; using Microsoft.Extensions.Logging; using TeamsISO.App.ViewModels; using TeamsISO.Engine.Controller; +using TeamsISO.Engine.Domain; namespace TeamsISO.App.Services; @@ -243,34 +244,37 @@ public sealed class ControlSurfaceServer : IAsyncDisposable return; } - // GET /participants/{id}/thumbnail.jpg — small JPEG of the latest + // GET /participants/{id}/thumbnail.bmp — small BMP of the latest // processed frame. Returns 404 when no pipeline is running for // this participant. The HTML control panel uses this URL with // a cache-busting query param every ~1s to drive live preview - // tiles. JPEG (not PNG) for ~10x smaller payload at the size - // we serve; quality 60 is plenty for a 192-wide thumbnail. + // tiles. BMP (not JPEG) because WPF imaging types NRE from + // non-UI threads and BMP encodes in plain managed code; the + // 40KB payload at 192-wide compresses fine over LAN gzip. + // Old /thumbnail.jpg URL accepted for backward compat. if (req.HttpMethod == "GET" && path.StartsWith("/participants/", StringComparison.Ordinal) - && path.EndsWith("/thumbnail.jpg", StringComparison.Ordinal)) + && (path.EndsWith("/thumbnail.bmp", StringComparison.Ordinal) || path.EndsWith("/thumbnail.jpg", StringComparison.Ordinal))) { + var ext = path.EndsWith("/thumbnail.bmp", StringComparison.Ordinal) ? "/thumbnail.bmp" : "/thumbnail.jpg"; var idSegment = path.AsSpan("/participants/".Length, - path.Length - "/participants/".Length - "/thumbnail.jpg".Length).ToString(); + path.Length - "/participants/".Length - ext.Length).ToString(); if (!Guid.TryParse(idSegment, out var thumbId)) { res.StatusCode = 400; await WriteJsonAsync(res, new { error = "invalid id" }); return; } - var jpeg = TryEncodeThumbnailJpeg(thumbId); - if (jpeg is null) + var bmp = TryEncodeThumbnailJpeg(thumbId); + if (bmp is null) { res.StatusCode = 404; await WriteJsonAsync(res, new { error = "no frame", id = thumbId }); return; } - res.ContentType = "image/jpeg"; + res.ContentType = "image/bmp"; res.AddHeader("Cache-Control", "no-store, must-revalidate"); - res.ContentLength64 = jpeg.Length; - await res.OutputStream.WriteAsync(jpeg); + res.ContentLength64 = bmp.Length; + await res.OutputStream.WriteAsync(bmp); return; } @@ -292,6 +296,12 @@ public sealed class ControlSurfaceServer : IAsyncDisposable ("GET", "/topology") => GetTopology(), ("POST", "/topology/apply") => await ApplyTopologyAsync(), ("POST", "/topology/restore") => await RestoreTopologyAsync(), + _ when req.HttpMethod == "POST" && path.StartsWith("/participants/", StringComparison.Ordinal) + && path.EndsWith("/override", StringComparison.Ordinal) + => await SetIsoOverrideByIdAsync(path, body), + _ when req.HttpMethod == "DELETE" && path.StartsWith("/participants/", StringComparison.Ordinal) + && path.EndsWith("/override", StringComparison.Ordinal) + => await ClearIsoOverrideByIdAsync(path), ("POST", "/notes") => AppendNote(body, req.QueryString), ("POST", "/participants/iso") => await ToggleIsoByNameAsync(body, req.QueryString), _ when req.HttpMethod == "POST" && path.StartsWith("/participants/", StringComparison.Ordinal) @@ -383,16 +393,96 @@ public sealed class ControlSurfaceServer : IAsyncDisposable // ParticipantViewModel property reads chase data-binding state. var dispatcher = System.Windows.Application.Current?.Dispatcher; if (dispatcher is null) return new { participants = Array.Empty() }; - var list = dispatcher.Invoke(() => vm.Participants.Select(p => (object)new - { - id = p.Id, - displayName = p.DisplayName, - isOnline = p.IsOnline, - isEnabled = p.IsEnabled, - customName = string.IsNullOrEmpty(p.CustomName) ? null : p.CustomName, - stateLabel = p.StateLabel, + var globals = _controller.GlobalSettings; + var list = dispatcher.Invoke(() => vm.Participants.Select(p => { + var ovr = _controller.GetIsoOverride(p.Id); + return (object)new + { + id = p.Id, + displayName = p.DisplayName, + isOnline = p.IsOnline, + isEnabled = p.IsEnabled, + customName = string.IsNullOrEmpty(p.CustomName) ? null : p.CustomName, + stateLabel = p.StateLabel, + // Effective settings = override if set, else globals. The web + // UI uses this to show the current per-row values without a + // separate round-trip to /global. + effective = new + { + framerate = (ovr ?? globals).Framerate.ToString(), + resolution = (ovr ?? globals).Resolution.ToString(), + aspect = (ovr ?? globals).Aspect.ToString(), + audio = (ovr ?? globals).Audio.ToString(), + isOverride = ovr is not null, + }, + }; }).ToArray()); - return new { participants = list }; + return new { participants = list, globals = new { + framerate = globals.Framerate.ToString(), + resolution = globals.Resolution.ToString(), + aspect = globals.Aspect.ToString(), + audio = globals.Audio.ToString(), + } }; + } + + /// + /// POST /participants/{id}/override — set or replace the per-pipeline + /// override. Body fields: framerate (enum string), resolution (enum + /// string), aspect (enum string), audio (enum string). All fields are + /// optional; missing fields fall back to the current global value. + /// + private async Task SetIsoOverrideByIdAsync(string path, JsonElement body) + { + var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length != 3 || segments[0] != "participants" || segments[2] != "override") + return new { ok = false, error = "expected /participants/{id}/override" }; + if (!Guid.TryParse(segments[1], out var id)) + return new { ok = false, error = "invalid id" }; + + var g = _controller.GlobalSettings; + var framerate = TryParseEnum(body, "framerate", g.Framerate); + var resolution = TryParseEnum(body, "resolution", g.Resolution); + var aspect = TryParseEnum(body, "aspect", g.Aspect); + var audio = TryParseEnum(body, "audio", g.Audio); + var ovr = new FrameProcessingSettings(framerate, resolution, aspect, audio); + await _controller.SetIsoOverrideAsync(id, ovr, CancellationToken.None); + return new { ok = true, id, effective = new + { + framerate = ovr.Framerate.ToString(), + resolution = ovr.Resolution.ToString(), + aspect = ovr.Aspect.ToString(), + audio = ovr.Audio.ToString(), + isOverride = true, + } }; + } + + /// DELETE /participants/{id}/override — pipeline reverts to global settings. + private async Task ClearIsoOverrideByIdAsync(string path) + { + var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length != 3 || segments[0] != "participants" || segments[2] != "override") + return new { ok = false, error = "expected /participants/{id}/override" }; + if (!Guid.TryParse(segments[1], out var id)) + return new { ok = false, error = "invalid id" }; + await _controller.SetIsoOverrideAsync(id, null, CancellationToken.None); + return new { ok = true, id, cleared = true }; + } + + /// + /// Parse an enum value from a JSON body, falling back to a default when + /// the field is missing or the value doesn't match any enum member. + /// Case-insensitive. Used by SetIsoOverrideByIdAsync for the four + /// FrameProcessingSettings enums. + /// + private static TEnum TryParseEnum(JsonElement body, string field, TEnum fallback) + where TEnum : struct, Enum + { + if (body.ValueKind != JsonValueKind.Object) return fallback; + if (!body.TryGetProperty(field, out var prop)) return fallback; + if (prop.ValueKind != JsonValueKind.String) return fallback; + var s = prop.GetString(); + if (string.IsNullOrEmpty(s)) return fallback; + return Enum.TryParse(s, ignoreCase: true, out var result) ? result : fallback; } private object RefreshDiscovery() @@ -521,6 +611,108 @@ public sealed class ControlSurfaceServer : IAsyncDisposable /// this participant or the frame can't be encoded for any reason. /// private byte[]? TryEncodeThumbnailJpeg(Guid participantId) + { + // Encode as a raw 32-bpp BMP. BMP is trivial to write byte-by-byte + // and every browser decodes it. JPEG would be smaller, but the + // System.Windows.Media.Imaging path NREs on non-UI threads and + // marshaling 1Hz JPEG encodes through the WPF dispatcher hurts + // responsiveness. ~40KB per 192-wide BMP is fine over LAN. + try + { + var frame = _controller.GetLatestProcessedFrame(participantId); + if (frame is null) + { + _logger?.LogDebug("Thumbnail: no frame for {Id}", participantId); + return null; + } + if (frame.Pixels.Length == 0) + { + _logger?.LogDebug("Thumbnail: empty pixel buffer for {Id} ({W}x{H})", participantId, frame.Width, frame.Height); + return null; + } + + // Nearest-neighbor downscale to ~192 wide. Source is BGRA32. + const int targetWidth = 192; + var ratio = (double)frame.Height / frame.Width; + var targetHeight = Math.Max(1, (int)(targetWidth * ratio)); + return EncodeBmpDownscaled(frame.Pixels.Span, frame.Width, frame.Height, targetWidth, targetHeight); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Thumbnail encode failed for {Id}", participantId); + return null; + } + } + + /// + /// Nearest-neighbor downscale of a BGRA32 source then write a 32-bpp + /// top-down BMP. Returns the full BMP file bytes including the 14-byte + /// BMP header and 40-byte BITMAPINFOHEADER. Browsers handle it directly + /// (no JPEG / PNG codec needed in-process). + /// + private static byte[] EncodeBmpDownscaled(ReadOnlySpan srcBgra, int srcW, int srcH, int dstW, int dstH) + { + var pixelBytes = dstW * dstH * 4; + var bmp = new byte[54 + pixelBytes]; + + // BMP file header (14 bytes): 'BM', file size, reserved, pixel offset. + bmp[0] = (byte)'B'; bmp[1] = (byte)'M'; + WriteUInt32LE(bmp, 2, (uint)bmp.Length); + WriteUInt32LE(bmp, 6, 0); + WriteUInt32LE(bmp, 10, 54); + + // DIB header (BITMAPINFOHEADER, 40 bytes): negative height = top-down. + WriteUInt32LE(bmp, 14, 40); + WriteInt32LE(bmp, 18, dstW); + WriteInt32LE(bmp, 22, -dstH); + WriteUInt16LE(bmp, 26, 1); + WriteUInt16LE(bmp, 28, 32); + WriteUInt32LE(bmp, 30, 0); + WriteUInt32LE(bmp, 34, (uint)pixelBytes); + WriteUInt32LE(bmp, 38, 2835); + WriteUInt32LE(bmp, 42, 2835); + WriteUInt32LE(bmp, 46, 0); + WriteUInt32LE(bmp, 50, 0); + + // Nearest-neighbor downscale, top-down (matches negative-height header). + var srcStride = srcW * 4; + var dstOffset = 54; + for (var dy = 0; dy < dstH; dy++) + { + var sy = (int)((long)dy * srcH / dstH); + for (var dx = 0; dx < dstW; dx++) + { + var sx = (int)((long)dx * srcW / dstW); + var si = sy * srcStride + sx * 4; + bmp[dstOffset++] = srcBgra[si]; + bmp[dstOffset++] = srcBgra[si + 1]; + bmp[dstOffset++] = srcBgra[si + 2]; + bmp[dstOffset++] = srcBgra[si + 3]; + } + } + return bmp; + } + + private static void WriteUInt16LE(byte[] buf, int offset, ushort value) + { + buf[offset] = (byte)(value & 0xFF); + buf[offset + 1] = (byte)((value >> 8) & 0xFF); + } + + private static void WriteInt32LE(byte[] buf, int offset, int value) => WriteUInt32LE(buf, offset, (uint)value); + + private static void WriteUInt32LE(byte[] buf, int offset, uint value) + { + buf[offset] = (byte)(value & 0xFF); + buf[offset + 1] = (byte)((value >> 8) & 0xFF); + buf[offset + 2] = (byte)((value >> 16) & 0xFF); + buf[offset + 3] = (byte)((value >> 24) & 0xFF); + } + + // Legacy WPF-imaging path kept dead-coded for posterity. The BMP path + // above is what's wired through the endpoint. If we ever want JPEG + // again, marshal this to the dispatcher and call from there. + private byte[]? TryEncodeThumbnailJpeg_WpfDeadCode(Guid participantId) { try { @@ -531,7 +723,13 @@ public sealed class ControlSurfaceServer : IAsyncDisposable var ratio = (double)frame.Height / frame.Width; var targetHeight = Math.Max(1, (int)(targetWidth * ratio)); - // Use WPF imaging — bundled with the WindowsDesktop SDK, no extra refs. + // WPF imaging is NOT free-threaded by default: BitmapSource and + // friends own DispatcherObject affinity until Freeze() drops it. + // The control surface handler runs on an HttpListener thread (NOT + // the UI dispatcher), so every intermediate bitmap MUST be frozen + // before the next call touches it — otherwise we get a NRE deep + // in MIL when JpegBitmapEncoder.Save tries to walk the frame + // chain across thread boundaries. var stride = frame.Width * 4; var source = System.Windows.Media.Imaging.BitmapSource.Create( frame.Width, frame.Height, @@ -540,19 +738,28 @@ public sealed class ControlSurfaceServer : IAsyncDisposable null, frame.Pixels.ToArray(), stride); - var scaled = new System.Windows.Media.Imaging.TransformedBitmap( - source, - new System.Windows.Media.ScaleTransform( - (double)targetWidth / frame.Width, - (double)targetHeight / frame.Height)); + if (source.CanFreeze) source.Freeze(); + + var transform = new System.Windows.Media.ScaleTransform( + (double)targetWidth / frame.Width, + (double)targetHeight / frame.Height); + if (transform.CanFreeze) transform.Freeze(); + + var scaled = new System.Windows.Media.Imaging.TransformedBitmap(source, transform); + if (scaled.CanFreeze) scaled.Freeze(); + + var bitmapFrame = System.Windows.Media.Imaging.BitmapFrame.Create(scaled); + if (bitmapFrame.CanFreeze) bitmapFrame.Freeze(); + using var ms = new System.IO.MemoryStream(); var encoder = new System.Windows.Media.Imaging.JpegBitmapEncoder { QualityLevel = 60 }; - encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(scaled)); + encoder.Frames.Add(bitmapFrame); encoder.Save(ms); return ms.ToArray(); } - catch + catch (Exception ex) { + _logger?.LogDebug(ex, "Thumbnail encode failed for {Id}", participantId); return null; } } diff --git a/src/TeamsISO.App/ViewModels/IsoOverrideDialogViewModel.cs b/src/TeamsISO.App/ViewModels/IsoOverrideDialogViewModel.cs new file mode 100644 index 0000000..ce1f964 --- /dev/null +++ b/src/TeamsISO.App/ViewModels/IsoOverrideDialogViewModel.cs @@ -0,0 +1,135 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using TeamsISO.Engine.Controller; +using TeamsISO.Engine.Domain; + +namespace TeamsISO.App.ViewModels; + +/// +/// View-model for the per-participant override +/// editor. Holds the operator's in-progress choice of framerate / resolution / +/// aspect / audio for a single participant, and exposes Apply / Clear / Cancel +/// commands that drive the engine through . +/// +/// "Following global settings" state is reflected via , +/// which is true exactly when the participant currently has a non-null override +/// on the engine side (i.e. their pipeline beats the global defaults). +/// +public sealed class IsoOverrideDialogViewModel : ObservableObject +{ + private readonly IIsoController _controller; + private readonly ToastViewModel? _toast; + private TargetFramerate _framerate; + private TargetResolution _resolution; + private AspectMode _aspect; + private AudioMode _audio; + private bool _hasOverride; + + /// + /// Participant identity. The engine API is Guid-keyed; we cache the display + /// name once at construction so the dialog title doesn't need to re-resolve + /// the participant from the live list (and so it survives the participant + /// going offline mid-dialog). + /// + public Guid ParticipantId { get; } + public string DisplayName { get; } + + /// + /// Reference to the existing so the + /// dialog's ComboBoxes can bind directly to its Available* lists — there's + /// no point duplicating the Enum.GetValues calls here. + /// + public GlobalSettingsViewModel Settings { get; } + + /// + /// Action the dialog code-behind wires up so the VM can close the window + /// after Apply / Clear / Cancel without taking a Window dependency. + /// + public Action? RequestClose { get; set; } + + public IsoOverrideDialogViewModel( + IIsoController controller, + GlobalSettingsViewModel settings, + Guid participantId, + string displayName, + FrameProcessingSettings? currentOverride, + ToastViewModel? toast = null) + { + _controller = controller; + Settings = settings; + _toast = toast; + ParticipantId = participantId; + DisplayName = displayName; + + // Initialize the four enum values from the existing override (if any) + // or fall back to the global settings — the dialog should always open + // with values that already reflect what this pipeline is using. + var source = currentOverride ?? new FrameProcessingSettings( + settings.Framerate, + settings.Resolution, + settings.Aspect, + settings.Audio); + + _framerate = source.Framerate; + _resolution = source.Resolution; + _aspect = source.Aspect; + _audio = source.Audio; + _hasOverride = currentOverride is not null; + + ApplyCommand = new AsyncRelayCommand(ApplyAsync); + ClearCommand = new AsyncRelayCommand(ClearAsync, () => _hasOverride); + CancelCommand = new RelayCommand(() => RequestClose?.Invoke()); + } + + public TargetFramerate Framerate { get => _framerate; set => SetField(ref _framerate, value); } + public TargetResolution Resolution { get => _resolution; set => SetField(ref _resolution, value); } + public AspectMode Aspect { get => _aspect; set => SetField(ref _aspect, value); } + public AudioMode Audio { get => _audio; set => SetField(ref _audio, value); } + + /// + /// True when the participant currently has a non-null override (i.e. this + /// dialog opened on an already-overridden pipeline). Toggles the visibility + /// of the "Following global settings" indicator and gates the + /// . + /// + public bool HasOverride + { + get => _hasOverride; + private set + { + if (SetField(ref _hasOverride, value)) + { + OnPropertyChanged(nameof(FollowingGlobalsVisible)); + ClearCommand.RaiseCanExecuteChanged(); + } + } + } + + /// + /// Convenience for XAML visibility binding — true when we should show the + /// "Following global settings · Reset to global" affordance. + /// + public bool FollowingGlobalsVisible => _hasOverride; + + public AsyncRelayCommand ApplyCommand { get; } + public AsyncRelayCommand ClearCommand { get; } + public RelayCommand CancelCommand { get; } + + private async Task ApplyAsync() + { + var settings = new FrameProcessingSettings(_framerate, _resolution, _aspect, _audio); + await _controller.SetIsoOverrideAsync(ParticipantId, settings, CancellationToken.None); + HasOverride = true; + _toast?.Show($"Override saved for {DisplayName}"); + RequestClose?.Invoke(); + } + + private async Task ClearAsync() + { + await _controller.SetIsoOverrideAsync(ParticipantId, null, CancellationToken.None); + HasOverride = false; + _toast?.Show($"{DisplayName} now follows global settings"); + RequestClose?.Invoke(); + } +} diff --git a/src/TeamsISO.App/Views/IsoOverrideDialog.xaml b/src/TeamsISO.App/Views/IsoOverrideDialog.xaml new file mode 100644 index 0000000..5100341 --- /dev/null +++ b/src/TeamsISO.App/Views/IsoOverrideDialog.xaml @@ -0,0 +1,173 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +