dragon-iso/src/TeamsISO.Engine/Pipeline/NdiSender.cs
Zac Gaetano 909237f454 feat(ndi): plumb NDI groups (discovery + output) through the engine
Adds an NdiGroupSettings record carrying optional comma-separated NDI group lists for the finder and the senders. Extends INdiInterop.CreateFinder / CreateSender with optional groups arguments and populates NDIlib_find_create_t.p_groups and NDIlib_send_create_t.p_groups via P/Invoke. IsoController reads the settings on construction, threads DiscoveryGroups into NdiDiscoveryService and OutputGroups into IsoPipelineConfig, and exposes SetGroupSettingsAsync for runtime updates (group changes apply on next process restart so live pipelines aren't orphaned).

This unblocks the 'transcoder' topology where Teams broadcasts NDI on a private group (e.g. teamsiso-input) and TeamsISO re-emits clean normalized streams on Public — keeping raw, wrong-framerate Teams sources off the production network.

EngineConfig schema is JSON-back-compat: existing config.json files (no NdiGroups field) deserialize with NdiGroups=null and load as NdiGroupSettings.Default. UI surface for these settings comes in a follow-up.

Tests: 72/72 passing (was 69) — added IsoController coverage that group settings are read from ConfigStore on startup, passed to the finder, threaded into per-pipeline config, and round-trip through SetGroupSettingsAsync/Save/Load.
2026-05-07 23:48:49 -04:00

71 lines
2.3 KiB
C#

using System.Threading.Channels;
using Microsoft.Extensions.Logging;
using TeamsISO.Engine.Interop;
namespace TeamsISO.Engine.Pipeline;
/// <summary>
/// Pulls processed frames from a channel and forwards them to <see cref="INdiInterop.SendFrame"/>.
/// </summary>
public sealed class NdiSender : IDisposable
{
private readonly INdiInterop _interop;
private readonly string _outputName;
private readonly ChannelReader<ProcessedFrame> _input;
private readonly ILogger<NdiSender> _logger;
private readonly NdiSenderHandle _handle;
private long _framesSent;
public NdiSender(
INdiInterop interop,
string outputName,
ChannelReader<ProcessedFrame> input,
ILogger<NdiSender> logger,
string? outputGroups = null)
{
_interop = interop;
_outputName = outputName;
_input = input;
_logger = logger;
_handle = interop.CreateSender(outputName, outputGroups);
}
public long FramesSent => Interlocked.Read(ref _framesSent);
/// <summary>
/// Awaits one frame and forwards it. Returns false if the channel is completed.
/// Test seam.
/// </summary>
public async ValueTask<bool> SendNextAsync(CancellationToken cancellationToken)
{
if (!await _input.WaitToReadAsync(cancellationToken))
return false;
if (!_input.TryRead(out var frame))
return false;
_interop.SendFrame(_handle, frame);
Interlocked.Increment(ref _framesSent);
return true;
}
/// <summary>Long-running send loop. Run on a dedicated thread.</summary>
public Task RunAsync(CancellationToken cancellationToken) =>
Task.Factory.StartNew(async () =>
{
try
{
while (!cancellationToken.IsCancellationRequested)
{
var more = await SendNextAsync(cancellationToken);
if (!more) break;
}
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
_logger.LogError(ex, "NdiSender loop crashed for output {Output}.", _outputName);
throw;
}
}, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap();
public void Dispose() => _handle.Dispose();
}