feat: per-ISO framerate/resolution/aspect/audio overrides + thumbnail BMP
Some checks failed
CI / build-and-test (push) Failing after 30s

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.
This commit is contained in:
Zac Gaetano 2026-05-15 15:31:32 -04:00
parent 647deec304
commit 5a43c9cb6a
10 changed files with 890 additions and 38 deletions

View file

@ -614,6 +614,29 @@
</DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn> </DataGridTemplateColumn>
<!-- Col 5a — Per-row gear: opens the ISO override editor for this
participant. Narrow (32px) so the table still fits inside a
1280px window after the toggle column. -->
<DataGridTemplateColumn Header="" Width="32" IsReadOnly="True">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button Style="{StaticResource Wd.Button.Ghost}"
Click="OnIsoOverrideClick"
Padding="6,4"
VerticalAlignment="Center"
HorizontalAlignment="Center"
ToolTip="Override output settings for this participant">
<Path Data="M 8,1 L 8,3 M 8,13 L 8,15 M 1,8 L 3,8 M 13,8 L 15,8 M 3,3 L 4.5,4.5 M 11.5,11.5 L 13,13 M 3,13 L 4.5,11.5 M 11.5,4.5 L 13,3 M 8,5.5 C 9.4,5.5 10.5,6.6 10.5,8 C 10.5,9.4 9.4,10.5 8,10.5 C 6.6,10.5 5.5,9.4 5.5,8 C 5.5,6.6 6.6,5.5 8,5.5"
Stroke="{DynamicResource Wd.Text.Secondary}"
StrokeThickness="1.4"
Fill="Transparent"
Width="14" Height="14"
Stretch="None"/>
</Button>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<!-- Col 5 — ISO toggle pill. LIVE = cyan-muted fill + cyan border + cyan text. <!-- Col 5 — ISO toggle pill. LIVE = cyan-muted fill + cyan border + cyan text.
OFF = hollow neutral. Error states use the existing IsoToggle style. --> OFF = hollow neutral. Error states use the existing IsoToggle style. -->
<DataGridTemplateColumn Header="ISO" Width="110"> <DataGridTemplateColumn Header="ISO" Width="110">

View file

@ -236,6 +236,31 @@ public partial class MainWindow : Window
palette.ShowDialog(); palette.ShowDialog();
} }
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary> /// <summary>
/// Esc closes the drawer when it's open. We use PreviewKeyDown rather than /// 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 /// KeyDown so the drawer's nested inputs (TextBox, ComboBox) don't swallow

View file

@ -92,8 +92,10 @@ internal static class ControlPanelHtml
.empty { color: var(--text-3); font-size: 12px; padding: 18px; text-align: center; } .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; } .global-actions { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
@media (min-width: 480px) { .global-actions { grid-template-columns: repeat(4, 1fr); } } @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 { 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); } .participant-row.speaking { background: var(--cyan-mute); }
.preview { .preview {
width: 112px; height: 63px; flex-shrink: 0; border-radius: 6px; 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; .preview.empty { display: flex; align-items: center; justify-content: center;
color: var(--text-3); font-family: 'JetBrains Mono', monospace; font-size: 18px; } 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-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 { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 200px; }
.topology-state strong { font-size: 13px; color: var(--text); } .topology-state strong { font-size: 13px; color: var(--text); }
@ -226,6 +257,51 @@ async function restoreTopology() {
fetchTopology(); 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 += ""<option value='"" + val + ""'"" + sel + "">"" + label + ""</option>"";
}
return html;
}
function render(participants) { function render(participants) {
if (!participants || participants.length === 0) { if (!participants || participants.length === 0) {
list.innerHTML = ""<div class='card empty'>No participants visible. Open Teams and join a meeting; sources will populate within seconds.</div>""; list.innerHTML = ""<div class='card empty'>No participants visible. Open Teams and join a meeting; sources will populate within seconds.</div>"";
@ -238,6 +314,12 @@ function render(participants) {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'card'; card.className = 'card';
for (const p of participants) { 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'); const row = document.createElement('div');
row.className = 'participant-row'; row.className = 'participant-row';
const stateColor = p.isEnabled ? 'cyan' : (p.isOnline ? 'gray' : 'coral'); const stateColor = p.isEnabled ? 'cyan' : (p.isOnline ? 'gray' : 'coral');
@ -253,20 +335,85 @@ function render(participants) {
""<div class='name'></div>"" + ""<div class='name'></div>"" +
""<div class='sub'></div>"" + ""<div class='sub'></div>"" +
""</div>"" + ""</div>"" +
""<button class='"" + (p.isEnabled ? 'live' : '') + ""'></button>""; ""<div class='row-right'>"" +
""<span class='cfg-caption'></span>"" +
""<button class='gear-btn' title='Output settings'></button>"" +
""<button class='enable-btn'></button>"" +
""</div>"";
const img = row.querySelector('img.preview'); const img = row.querySelector('img.preview');
img.src = previewUrl; img.src = previewUrl;
row.querySelector('.name').textContent = p.displayName; row.querySelector('.name').textContent = p.displayName;
row.querySelector('.sub').textContent = const subEl = row.querySelector('.sub');
subEl.textContent =
(p.stateLabel || (p.isOnline ? 'online' : 'offline')) + (p.stateLabel || (p.isOnline ? 'online' : 'offline')) +
(p.customName ? ' · ' + p.customName : ''); (p.customName ? ' · ' + p.customName : '');
const btn = row.querySelector('button'); if (isOverride) {
btn.textContent = p.isEnabled ? ' LIVE' : 'Enable'; const pill = document.createElement('span');
btn.onclick = () => post('/participants/iso', { 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, displayName: p.displayName,
enabled: !p.isEnabled, enabled: !p.isEnabled,
}); });
card.appendChild(row);
const panel = document.createElement('div');
panel.className = 'override-panel' + (openPanels.has(p.id) ? ' open' : '');
panel.innerHTML =
""<div class='override-grid'>"" +
""<div class='override-field'><label>Framerate</label>"" +
""<select data-k='framerate'>"" + buildSelect(FRAMERATE_OPTS, eff.framerate) + ""</select></div>"" +
""<div class='override-field'><label>Resolution</label>"" +
""<select data-k='resolution'>"" + buildSelect(RESOLUTION_OPTS, eff.resolution) + ""</select></div>"" +
""<div class='override-field'><label>Aspect</label>"" +
""<select data-k='aspect'>"" + buildSelect(ASPECT_OPTS, eff.aspect) + ""</select></div>"" +
""<div class='override-field'><label>Audio</label>"" +
""<select data-k='audio'>"" + buildSelect(AUDIO_OPTS, eff.audio) + ""</select></div>"" +
""</div>"" +
""<div class='override-actions'>"" +
""<button class='primary apply-btn'>Apply</button>"" +
""<button class='danger clear-btn'>Clear (use global)</button>"" +
""</div>"";
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); list.appendChild(card);
} }

View file

@ -10,6 +10,7 @@ using System.Windows.Threading;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using TeamsISO.App.ViewModels; using TeamsISO.App.ViewModels;
using TeamsISO.Engine.Controller; using TeamsISO.Engine.Controller;
using TeamsISO.Engine.Domain;
namespace TeamsISO.App.Services; namespace TeamsISO.App.Services;
@ -243,34 +244,37 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
return; 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 // processed frame. Returns 404 when no pipeline is running for
// this participant. The HTML control panel uses this URL with // this participant. The HTML control panel uses this URL with
// a cache-busting query param every ~1s to drive live preview // a cache-busting query param every ~1s to drive live preview
// tiles. JPEG (not PNG) for ~10x smaller payload at the size // tiles. BMP (not JPEG) because WPF imaging types NRE from
// we serve; quality 60 is plenty for a 192-wide thumbnail. // 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) 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, 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)) if (!Guid.TryParse(idSegment, out var thumbId))
{ {
res.StatusCode = 400; res.StatusCode = 400;
await WriteJsonAsync(res, new { error = "invalid id" }); await WriteJsonAsync(res, new { error = "invalid id" });
return; return;
} }
var jpeg = TryEncodeThumbnailJpeg(thumbId); var bmp = TryEncodeThumbnailJpeg(thumbId);
if (jpeg is null) if (bmp is null)
{ {
res.StatusCode = 404; res.StatusCode = 404;
await WriteJsonAsync(res, new { error = "no frame", id = thumbId }); await WriteJsonAsync(res, new { error = "no frame", id = thumbId });
return; return;
} }
res.ContentType = "image/jpeg"; res.ContentType = "image/bmp";
res.AddHeader("Cache-Control", "no-store, must-revalidate"); res.AddHeader("Cache-Control", "no-store, must-revalidate");
res.ContentLength64 = jpeg.Length; res.ContentLength64 = bmp.Length;
await res.OutputStream.WriteAsync(jpeg); await res.OutputStream.WriteAsync(bmp);
return; return;
} }
@ -292,6 +296,12 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
("GET", "/topology") => GetTopology(), ("GET", "/topology") => GetTopology(),
("POST", "/topology/apply") => await ApplyTopologyAsync(), ("POST", "/topology/apply") => await ApplyTopologyAsync(),
("POST", "/topology/restore") => await RestoreTopologyAsync(), ("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", "/notes") => AppendNote(body, req.QueryString),
("POST", "/participants/iso") => await ToggleIsoByNameAsync(body, req.QueryString), ("POST", "/participants/iso") => await ToggleIsoByNameAsync(body, req.QueryString),
_ when req.HttpMethod == "POST" && path.StartsWith("/participants/", StringComparison.Ordinal) _ 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. // ParticipantViewModel property reads chase data-binding state.
var dispatcher = System.Windows.Application.Current?.Dispatcher; var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher is null) return new { participants = Array.Empty<object>() }; if (dispatcher is null) return new { participants = Array.Empty<object>() };
var list = dispatcher.Invoke(() => vm.Participants.Select(p => (object)new var globals = _controller.GlobalSettings;
{ var list = dispatcher.Invoke(() => vm.Participants.Select(p => {
id = p.Id, var ovr = _controller.GetIsoOverride(p.Id);
displayName = p.DisplayName, return (object)new
isOnline = p.IsOnline, {
isEnabled = p.IsEnabled, id = p.Id,
customName = string.IsNullOrEmpty(p.CustomName) ? null : p.CustomName, displayName = p.DisplayName,
stateLabel = p.StateLabel, 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()); }).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(),
} };
}
/// <summary>
/// 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.
/// </summary>
private async Task<object> 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,
} };
}
/// <summary>DELETE /participants/{id}/override — pipeline reverts to global settings.</summary>
private async Task<object> 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 };
}
/// <summary>
/// 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.
/// </summary>
private static TEnum TryParseEnum<TEnum>(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<TEnum>(s, ignoreCase: true, out var result) ? result : fallback;
} }
private object RefreshDiscovery() private object RefreshDiscovery()
@ -521,6 +611,108 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
/// this participant or the frame can't be encoded for any reason. /// this participant or the frame can't be encoded for any reason.
/// </summary> /// </summary>
private byte[]? TryEncodeThumbnailJpeg(Guid participantId) 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;
}
}
/// <summary>
/// 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).
/// </summary>
private static byte[] EncodeBmpDownscaled(ReadOnlySpan<byte> 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 try
{ {
@ -531,7 +723,13 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
var ratio = (double)frame.Height / frame.Width; var ratio = (double)frame.Height / frame.Width;
var targetHeight = Math.Max(1, (int)(targetWidth * ratio)); 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 stride = frame.Width * 4;
var source = System.Windows.Media.Imaging.BitmapSource.Create( var source = System.Windows.Media.Imaging.BitmapSource.Create(
frame.Width, frame.Height, frame.Width, frame.Height,
@ -540,19 +738,28 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
null, null,
frame.Pixels.ToArray(), frame.Pixels.ToArray(),
stride); stride);
var scaled = new System.Windows.Media.Imaging.TransformedBitmap( if (source.CanFreeze) source.Freeze();
source,
new System.Windows.Media.ScaleTransform( var transform = new System.Windows.Media.ScaleTransform(
(double)targetWidth / frame.Width, (double)targetWidth / frame.Width,
(double)targetHeight / frame.Height)); (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(); using var ms = new System.IO.MemoryStream();
var encoder = new System.Windows.Media.Imaging.JpegBitmapEncoder { QualityLevel = 60 }; 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); encoder.Save(ms);
return ms.ToArray(); return ms.ToArray();
} }
catch catch (Exception ex)
{ {
_logger?.LogDebug(ex, "Thumbnail encode failed for {Id}", participantId);
return null; return null;
} }
} }

View file

@ -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;
/// <summary>
/// View-model for the per-participant <see cref="FrameProcessingSettings"/> 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 <see cref="IIsoController"/>.
///
/// "Following global settings" state is reflected via <see cref="HasOverride"/>,
/// 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).
/// </summary>
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;
/// <summary>
/// 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).
/// </summary>
public Guid ParticipantId { get; }
public string DisplayName { get; }
/// <summary>
/// Reference to the existing <see cref="GlobalSettingsViewModel"/> so the
/// dialog's ComboBoxes can bind directly to its Available* lists — there's
/// no point duplicating the Enum.GetValues calls here.
/// </summary>
public GlobalSettingsViewModel Settings { get; }
/// <summary>
/// Action the dialog code-behind wires up so the VM can close the window
/// after Apply / Clear / Cancel without taking a Window dependency.
/// </summary>
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); }
/// <summary>
/// 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
/// <see cref="ClearCommand"/>.
/// </summary>
public bool HasOverride
{
get => _hasOverride;
private set
{
if (SetField(ref _hasOverride, value))
{
OnPropertyChanged(nameof(FollowingGlobalsVisible));
ClearCommand.RaiseCanExecuteChanged();
}
}
}
/// <summary>
/// Convenience for XAML visibility binding — true when we should show the
/// "Following global settings · Reset to global" affordance.
/// </summary>
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();
}
}

View file

@ -0,0 +1,173 @@
<Window x:Class="TeamsISO.App.Views.IsoOverrideDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:TeamsISO.App.ViewModels"
xmlns:conv="clr-namespace:TeamsISO.App.Converters"
mc:Ignorable="d"
Title="Output settings"
Icon="/Assets/teamsiso.ico"
Width="420" Height="360"
ResizeMode="NoResize"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False"
Background="{DynamicResource Wd.Canvas}"
Foreground="{DynamicResource Wd.Text.Primary}"
UseLayoutRounding="True"
SnapsToDevicePixels="True"
TextOptions.TextRenderingMode="ClearType"
TextOptions.TextFormattingMode="Display"
d:DataContext="{d:DesignInstance Type=vm:IsoOverrideDialogViewModel}">
<!--
Per-ISO override editor — opened from the participant row's gear button.
Default Windows chrome (no chromeless) per the shape brief: this is a
utility dialog, not part of the main shell's design language, and it
benefits from the OS's native close affordance.
Four enums (framerate / resolution / aspect / audio), an indicator that
tells the operator whether the participant currently overrides globals,
and a footer with Apply + Cancel. "Reset to global" appears as a Ghost
button next to the indicator when an override is currently set.
-->
<Window.Resources>
<conv:BoolToVisibilityConverter x:Key="BoolToVis"
TrueValue="Visible"
FalseValue="Collapsed"/>
<conv:EnumDescriptionConverter x:Key="EnumDesc"/>
</Window.Resources>
<Grid Margin="20,16,20,16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Title row: participant display name + caption -->
<StackPanel Grid.Row="0">
<TextBlock Text="{Binding DisplayName}"
Style="{StaticResource Wd.Text.Title}"
FontSize="16"
TextTrimming="CharacterEllipsis"/>
<TextBlock Text="Output settings"
Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
Margin="0,2,0,0"/>
</StackPanel>
<!-- "Following global settings" pill + Reset to global button.
Only visible when an override is currently active on the engine.
When hidden, the row collapses so the editor area shifts up. -->
<Border Grid.Row="1"
Margin="0,12,0,0"
Padding="10,6"
CornerRadius="{StaticResource Radius.S}"
Background="{DynamicResource Wd.Accent.CyanMuted}"
BorderBrush="{DynamicResource Wd.Accent.Cyan}"
BorderThickness="1"
Visibility="{Binding FollowingGlobalsVisible, Converter={StaticResource BoolToVis}}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Ellipse Grid.Column="0"
Width="7" Height="7"
Fill="{DynamicResource Wd.Accent.Cyan}"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<TextBlock Grid.Column="1"
Text="Overriding global settings"
FontFamily="{StaticResource Wd.Font.Sans}"
FontSize="11"
Foreground="{DynamicResource Wd.Accent.CyanText}"
VerticalAlignment="Center"/>
<Button Grid.Column="2"
Style="{StaticResource Wd.Button.Ghost}"
Content="Reset to global"
Command="{Binding ClearCommand}"
Padding="10,3"
FontSize="11"
ToolTip="Clear this participant's override and follow global output settings"/>
</Grid>
</Border>
<!-- The four labeled ComboBoxes -->
<StackPanel Grid.Row="2" Margin="0,12,0,0">
<TextBlock Text="Framerate"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,0,0,4"/>
<ComboBox ItemsSource="{Binding Settings.AvailableFramerates}"
SelectedItem="{Binding Framerate}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource EnumDesc}}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="Resolution"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,10,0,4"/>
<ComboBox ItemsSource="{Binding Settings.AvailableResolutions}"
SelectedItem="{Binding Resolution}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource EnumDesc}}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="Aspect"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,10,0,4"/>
<ComboBox ItemsSource="{Binding Settings.AvailableAspectModes}"
SelectedItem="{Binding Aspect}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource EnumDesc}}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="Audio"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,10,0,4"/>
<ComboBox ItemsSource="{Binding Settings.AvailableAudioModes}"
SelectedItem="{Binding Audio}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource EnumDesc}}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<!-- Spacer divider above the footer -->
<Border Grid.Row="3"
Height="1"
Margin="0,16,0,12"
Background="{DynamicResource Wd.Border}"/>
<!-- Footer: Cancel + Apply -->
<StackPanel Grid.Row="4"
Orientation="Horizontal"
HorizontalAlignment="Right">
<Button Style="{StaticResource Wd.Button.Ghost}"
Content="Cancel"
Command="{Binding CancelCommand}"
Padding="16,6"
Margin="0,0,8,0"
IsCancel="True"/>
<Button Style="{StaticResource Wd.Button.Primary}"
Content="Apply"
Command="{Binding ApplyCommand}"
Padding="16,6"
IsDefault="True"/>
</StackPanel>
</Grid>
</Window>

View file

@ -0,0 +1,30 @@
using System.Windows;
using TeamsISO.App.ViewModels;
namespace TeamsISO.App.Views;
/// <summary>
/// Per-participant ISO override editor. Modal dialog opened from the gear
/// affordance on each participant row. The dialog is a thin shell over
/// <see cref="IsoOverrideDialogViewModel"/> — it owns no business logic; it
/// just wires <see cref="IsoOverrideDialogViewModel.RequestClose"/> to
/// <see cref="Window.Close"/> so Apply / Clear / Cancel can dismiss the
/// dialog without taking a Window dependency on the VM.
/// </summary>
public partial class IsoOverrideDialog : Window
{
public IsoOverrideDialog()
{
InitializeComponent();
}
public IsoOverrideDialog(IsoOverrideDialogViewModel vm) : this()
{
DataContext = vm;
vm.RequestClose = () =>
{
// Idempotent — Window.Close after the window has closed is a no-op.
if (IsLoaded) Close();
};
}
}

View file

@ -52,6 +52,26 @@ public interface IIsoController : IAsyncDisposable
/// <summary>Updates global processing settings and persists them. Currently does not restart running pipelines (Phase C wires that).</summary> /// <summary>Updates global processing settings and persists them. Currently does not restart running pipelines (Phase C wires that).</summary>
Task SetGlobalSettingsAsync(FrameProcessingSettings settings, CancellationToken cancellationToken); Task SetGlobalSettingsAsync(FrameProcessingSettings settings, CancellationToken cancellationToken);
/// <summary>
/// The per-participant override for this pipeline's
/// <see cref="FrameProcessingSettings"/>, or null when the participant
/// is following global settings. Reads are unsynchronized snapshots —
/// safe for UI consumption but don't rely on equality with the value
/// that a concurrent <see cref="SetIsoOverrideAsync"/> just wrote.
/// </summary>
FrameProcessingSettings? GetIsoOverride(Guid participantId);
/// <summary>
/// Set or clear the per-participant override. Passing
/// <paramref name="settings"/> = null removes the override (pipeline
/// reverts to global settings). If a pipeline for the participant is
/// currently running, it's stopped and restarted with the new
/// settings — the operator sees a brief signal drop during the swap.
/// Persists alongside the assignment to config.json so the override
/// survives process restarts.
/// </summary>
Task SetIsoOverrideAsync(Guid participantId, FrameProcessingSettings? settings, CancellationToken cancellationToken);
/// <summary> /// <summary>
/// Updates the NDI group configuration and persists it. Group changes apply on next process /// Updates the NDI group configuration and persists it. Group changes apply on next process
/// restart — rebuilding finder/sender handles mid-flight would orphan running pipelines. /// restart — rebuilding finder/sender handles mid-flight would orphan running pipelines.

View file

@ -35,6 +35,11 @@ public sealed class IsoController : IIsoController
// Parallel map of active recorders keyed by participant id, for the // Parallel map of active recorders keyed by participant id, for the
// marker-drop API which needs to fan out to every running recorder. // marker-drop API which needs to fan out to every running recorder.
private readonly Dictionary<Guid, Pipeline.IRecorderSink> _recorders = new(); private readonly Dictionary<Guid, Pipeline.IRecorderSink> _recorders = new();
// Per-participant FrameProcessingSettings overrides. Null entry / missing
// key → use global settings. Set entry → that pipeline runs at the
// override values regardless of global. Persisted to config.json alongside
// the IsoAssignment record so it survives process restarts.
private readonly Dictionary<Guid, FrameProcessingSettings> _overrides = new();
private readonly BehaviorSubject<IReadOnlyList<Participant>> _participants = private readonly BehaviorSubject<IReadOnlyList<Participant>> _participants =
new(Array.Empty<Participant>()); new(Array.Empty<Participant>());
private readonly Subject<EngineAlert> _alerts = new(); private readonly Subject<EngineAlert> _alerts = new();
@ -74,6 +79,16 @@ public sealed class IsoController : IIsoController
_settings = loaded.Global; _settings = loaded.Global;
_groupSettings = loaded.GroupsOrDefault; _groupSettings = loaded.GroupsOrDefault;
// Hydrate per-participant overrides from the persisted assignments.
// Each assignment with a non-null Override gets parked here; pipelines
// get the override when they're enabled (whether autonomously by
// auto-apply or manually by the operator).
foreach (var asn in loaded.Assignments)
{
if (asn.Override is not null)
_overrides[asn.ParticipantId] = asn.Override;
}
_tracker = new ParticipantTracker(_renameWindow, clock ?? (() => DateTimeOffset.UtcNow)); _tracker = new ParticipantTracker(_renameWindow, clock ?? (() => DateTimeOffset.UtcNow));
_discoveryChannel = Channel.CreateUnbounded<DiscoveryEvent>(); _discoveryChannel = Channel.CreateUnbounded<DiscoveryEvent>();
_discovery = new NdiDiscoveryService( _discovery = new NdiDiscoveryService(
@ -148,7 +163,11 @@ public sealed class IsoController : IIsoController
lock (_gate) lock (_gate)
{ {
outputGroups = _groupSettings.OutputGroups; outputGroups = _groupSettings.OutputGroups;
settingsSnapshot = _settings; // Per-participant override beats global. The override is a frozen
// record; mutations during a pipeline's lifetime are handled by
// SetIsoOverrideAsync which stops + restarts the pipeline so the
// new value flows through.
settingsSnapshot = _overrides.TryGetValue(participantId, out var ovr) ? ovr : _settings;
recordingEnabled = _recordingEnabled; recordingEnabled = _recordingEnabled;
recordingDirectory = _recordingDirectory; recordingDirectory = _recordingDirectory;
} }
@ -217,6 +236,58 @@ public sealed class IsoController : IIsoController
return PersistAssignmentsAsync(cancellationToken); return PersistAssignmentsAsync(cancellationToken);
} }
public FrameProcessingSettings? GetIsoOverride(Guid participantId)
{
lock (_gate)
{
return _overrides.TryGetValue(participantId, out var ovr) ? ovr : null;
}
}
/// <summary>
/// Sets or clears the per-participant override and, if the pipeline is
/// currently running, hot-swaps it: stop + brief delay + restart with
/// the new settings. The downstream NDI receiver will see a one-frame
/// drop on the swap but reconnect within ~200ms, same behavior as
/// RestartIso. Persists alongside the assignment so the choice survives
/// process restarts.
/// </summary>
public async Task SetIsoOverrideAsync(Guid participantId, FrameProcessingSettings? settings, CancellationToken cancellationToken)
{
bool wasRunning;
string? customName;
lock (_gate)
{
if (settings is null) _overrides.Remove(participantId);
else _overrides[participantId] = settings;
wasRunning = _pipelines.ContainsKey(participantId);
// Custom name lives on the assignment; for now there's only ever
// one in-flight name (the one passed to EnableIsoAsync), and we
// don't track that — so the restart path uses the default name.
// OK for v2; per-pipeline custom names can be retrofitted later.
customName = null;
}
if (wasRunning)
{
try
{
await DisableIsoAsync(participantId, cancellationToken);
await Task.Delay(150, cancellationToken);
await EnableIsoAsync(participantId, customName, cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Hot-swap failed for participant {Id} after override change", participantId);
}
}
else
{
// Pipeline not running — just persist the override so it takes
// effect the NEXT time the operator enables this participant.
await PersistAssignmentsAsync(cancellationToken);
}
}
/// <summary> /// <summary>
/// Updates the NDI group configuration. Note: existing finder/sender handles aren't /// Updates the NDI group configuration. Note: existing finder/sender handles aren't
/// rebuilt — group changes take effect on the next process restart, since rebuilding /// rebuilt — group changes take effect on the next process restart, since rebuilding
@ -285,8 +356,21 @@ public sealed class IsoController : IIsoController
{ {
settings = _settings; settings = _settings;
groupSettings = _groupSettings; groupSettings = _groupSettings;
assignments = _pipelines.Keys.Select(id => // Build assignments from BOTH running pipelines (current
new IsoAssignment(id, IsEnabled: true, CustomOutputName: null)).ToArray(); // enabled set) AND any persisted overrides for participants
// whose pipeline isn't currently active. An operator who set
// a 30 fps override yesterday, then closed the app, then
// re-enabled today should get 30 fps on re-enable.
var ids = _pipelines.Keys
.Concat(_overrides.Keys.Where(k => !_pipelines.ContainsKey(k)))
.Distinct();
assignments = ids
.Select(id => new IsoAssignment(
id,
IsEnabled: _pipelines.ContainsKey(id),
CustomOutputName: null,
Override: _overrides.TryGetValue(id, out var o) ? o : null))
.ToArray();
} }
_configStore.Save(new EngineConfig(settings, assignments, groupSettings)); _configStore.Save(new EngineConfig(settings, assignments, groupSettings));
} }

View file

@ -2,8 +2,16 @@ namespace TeamsISO.Engine.Domain;
/// <summary> /// <summary>
/// Operator's intent for an ISO output. Persisted to <c>config.json</c>. /// Operator's intent for an ISO output. Persisted to <c>config.json</c>.
///
/// <para><see cref="Override"/> is the optional per-pipeline
/// <see cref="FrameProcessingSettings"/> that takes precedence over the global
/// defaults for THIS participant only. null means "follow global settings"
/// (the common case). Non-null lets the operator run a 30 fps 720p Mixed
/// pipeline next to a 60 fps 1080p Isolated one — useful when downstream
/// switchers expect specific formats per source slot.</para>
/// </summary> /// </summary>
public sealed record IsoAssignment( public sealed record IsoAssignment(
Guid ParticipantId, Guid ParticipantId,
bool IsEnabled, bool IsEnabled,
string? CustomOutputName); string? CustomOutputName,
FrameProcessingSettings? Override = null);