feat: per-ISO framerate/resolution/aspect/audio overrides + thumbnail BMP
Some checks failed
CI / build-and-test (push) Failing after 30s
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:
parent
647deec304
commit
5a43c9cb6a
10 changed files with 890 additions and 38 deletions
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
135
src/TeamsISO.App/ViewModels/IsoOverrideDialogViewModel.cs
Normal file
135
src/TeamsISO.App/ViewModels/IsoOverrideDialogViewModel.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
173
src/TeamsISO.App/Views/IsoOverrideDialog.xaml
Normal file
173
src/TeamsISO.App/Views/IsoOverrideDialog.xaml
Normal 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>
|
||||||
30
src/TeamsISO.App/Views/IsoOverrideDialog.xaml.cs
Normal file
30
src/TeamsISO.App/Views/IsoOverrideDialog.xaml.cs
Normal 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();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue