feat(ui): real Wild Dragon mark in rail + automated transcoder topology
All checks were successful
CI / build-and-test (push) Successful in 31s
All checks were successful
CI / build-and-test (push) Successful in 31s
Two related deliverables addressing the user's morning asks. 1. Branding: Dragon WHITE.png and Wild Dragon Logo WHITE.png from the brand kit are copied into src/TeamsISO.App/Assets/ and registered as <Resource> items in the .csproj. The rail's placeholder 'W' glyph is replaced by the real dragon mark (40x40, HighQuality bitmap scaling) with a 'Wild Dragon' caption underneath. 2. NDI Access Manager automation: NdiAccessManagerConfig service reads/writes %APPDATA%\\NDI\\ndi-config.v1.json, working in JsonNode trees so we don't clobber unrelated keys. ApplyTranscoderTopology() sets groups.send=[teamsiso-input] and groups.recv=[public, teamsiso-input] so all local senders (Teams + anything else) broadcast on the private group while local receivers can still see public sources too. Engine-side, the user's per-pipeline OutputGroups override pushes TeamsISO outputs back onto Public so downstream switchers see clean ISOs. Atomic write: temp + replace, with timestamped backup of the prior config. ReadCurrentGroups() can be used by future UI to show what's currently configured. RestoreDefaults() reverts. Settings panel grows an 'Apply transcoder topology' button under the NDI Network section. Click writes the system config, sets the engine's discovery=teamsiso-input / output=public, refreshes the bound text boxes, and pops a dialog with a 'restart Teams' reminder + the backup path.
This commit is contained in:
parent
c08b90b0b2
commit
01ef4250d7
6 changed files with 265 additions and 19 deletions
BIN
src/TeamsISO.App/Assets/dragon-mark.png
Normal file
BIN
src/TeamsISO.App/Assets/dragon-mark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
BIN
src/TeamsISO.App/Assets/wild-dragon-wordmark.png
Normal file
BIN
src/TeamsISO.App/Assets/wild-dragon-wordmark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
|
|
@ -52,30 +52,21 @@
|
|||
BorderThickness="0,0,1,0">
|
||||
<DockPanel LastChildFill="False">
|
||||
|
||||
<!-- Wild Dragon logo: cyan circular mark with stylized "W" inside -->
|
||||
<Border DockPanel.Dock="Top"
|
||||
Width="40" Height="40"
|
||||
Margin="0,16,0,8"
|
||||
HorizontalAlignment="Center"
|
||||
CornerRadius="10"
|
||||
Background="{DynamicResource Wd.Accent.CyanMuted}"
|
||||
BorderBrush="{DynamicResource Wd.Accent.Cyan}"
|
||||
BorderThickness="1">
|
||||
<Path Data="M 10,8 L 14,24 L 17,16 L 20,24 L 24,8"
|
||||
Stroke="{DynamicResource Wd.Accent.Cyan}"
|
||||
StrokeThickness="2"
|
||||
StrokeStartLineCap="Round"
|
||||
StrokeEndLineCap="Round"
|
||||
StrokeLineJoin="Round"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<!-- Wild Dragon mark — real logo from wilddragon.net -->
|
||||
<Image DockPanel.Dock="Top"
|
||||
Source="/Assets/dragon-mark.png"
|
||||
Width="40" Height="40"
|
||||
Margin="0,18,0,4"
|
||||
HorizontalAlignment="Center"
|
||||
RenderOptions.BitmapScalingMode="HighQuality"
|
||||
ToolTip="Wild Dragon"/>
|
||||
|
||||
<TextBlock DockPanel.Dock="Top"
|
||||
Text="WD"
|
||||
Text="Wild Dragon"
|
||||
Style="{StaticResource Wd.Text.Caption}"
|
||||
Foreground="{DynamicResource Wd.Accent.Cyan}"
|
||||
HorizontalAlignment="Center"
|
||||
FontSize="9"
|
||||
Margin="0,0,0,16"/>
|
||||
|
||||
<!-- Divider -->
|
||||
|
|
@ -539,6 +530,22 @@
|
|||
Text="Group changes apply on next launch — running pipelines aren't restarted to avoid orphaning live ISOs."/>
|
||||
</Border>
|
||||
|
||||
<!-- One-click transcoder topology setup. Writes the system-wide
|
||||
NDI config so Teams broadcasts on a private group, then sets
|
||||
the engine to consume from that group and re-emit on Public. -->
|
||||
<Button Style="{StaticResource Wd.Button.Ghost}"
|
||||
Content="Apply transcoder topology"
|
||||
Command="{Binding Settings.ApplyTranscoderTopologyCommand}"
|
||||
HorizontalAlignment="Stretch"
|
||||
Margin="0,12,0,0"
|
||||
Padding="0,9"/>
|
||||
<TextBlock Style="{StaticResource Wd.Text.Body}"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
||||
TextWrapping="Wrap"
|
||||
Margin="0,4,0,0"
|
||||
Text="Pins Teams' raw NDI broadcasts to a private 'teamsiso-input' group so they don't pollute the Public network. Restart Teams after applying."/>
|
||||
|
||||
<!-- Display -->
|
||||
<TextBlock Text="DISPLAY"
|
||||
Style="{StaticResource Wd.Text.Caption}"
|
||||
|
|
|
|||
180
src/TeamsISO.App/Services/NdiAccessManagerConfig.cs
Normal file
180
src/TeamsISO.App/Services/NdiAccessManagerConfig.cs
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace TeamsISO.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reads and writes NDI Access Manager's per-user config at
|
||||
/// <c>%APPDATA%\NDI\ndi-config.v1.json</c>. This file controls global NDI behavior for
|
||||
/// every NDI application on the machine — sender groups, receiver groups, RUDP/TCP
|
||||
/// transport toggles, allowed adapters, etc. NDI applications read it on startup, so
|
||||
/// changes here only take effect after restarting the affected app (Teams, OBS, etc.).
|
||||
///
|
||||
/// We use it to implement the "transcoder topology" requested by the user: pin Teams'
|
||||
/// raw at-source-resolution NDI broadcasts to a private group (<c>teamsiso-input</c>) so
|
||||
/// they don't pollute the production network, while TeamsISO's own clean normalized ISO
|
||||
/// outputs continue to broadcast on the standard <c>Public</c> group that downstream
|
||||
/// switchers and recorders default to.
|
||||
///
|
||||
/// The shape of ndi-config.v1.json is documented in the NDI 6 SDK headers; we work in
|
||||
/// terms of <see cref="JsonNode"/> trees so we don't clobber unrelated keys (e.g. RUDP
|
||||
/// settings the user may have customized in Access Manager).
|
||||
/// </summary>
|
||||
public static class NdiAccessManagerConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Path to the NDI Access Manager config. <c>%APPDATA%\NDI\ndi-config.v1.json</c>.
|
||||
/// </summary>
|
||||
public static string ConfigPath =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"NDI",
|
||||
"ndi-config.v1.json");
|
||||
|
||||
/// <summary>
|
||||
/// Default name of the private group used for the transcoder topology.
|
||||
/// Matches the convention referenced in the NDI Network settings UI.
|
||||
/// </summary>
|
||||
public const string TranscoderInputGroup = "teamsiso-input";
|
||||
|
||||
/// <summary>
|
||||
/// Result of an apply attempt. <see cref="Success"/> indicates the file was
|
||||
/// written or already had the desired groups. <see cref="BackupPath"/> is set
|
||||
/// to the path of the saved-aside copy of the prior config (when one existed),
|
||||
/// so the user can revert if they don't like the change.
|
||||
/// </summary>
|
||||
public sealed record ApplyResult(
|
||||
bool Success,
|
||||
string ConfigPath,
|
||||
string? BackupPath,
|
||||
string? ErrorMessage);
|
||||
|
||||
/// <summary>
|
||||
/// Configures the machine-wide NDI groups so:
|
||||
/// <list type="bullet">
|
||||
/// <item>All local senders (Teams, anything else) broadcast on
|
||||
/// <paramref name="senderGroup"/> only — i.e. the private input group.</item>
|
||||
/// <item>All local receivers see both <paramref name="senderGroup"/> and
|
||||
/// <c>public</c> so TeamsISO can discover Teams' sources AND any
|
||||
/// standard public sources from elsewhere on the network.</item>
|
||||
/// </list>
|
||||
/// TeamsISO's own per-pipeline <c>OutputGroups</c> still overrides the per-machine
|
||||
/// default at the sender level, so its normalized ISO outputs go on Public.
|
||||
/// </summary>
|
||||
/// <param name="senderGroup">Private group name for Teams' raw broadcasts.</param>
|
||||
public static ApplyResult ApplyTranscoderTopology(string senderGroup = TranscoderInputGroup)
|
||||
{
|
||||
try
|
||||
{
|
||||
var root = LoadOrCreate();
|
||||
var ndi = EnsureObject(root, "ndi");
|
||||
var groups = EnsureObject(ndi, "groups");
|
||||
groups["send"] = new JsonArray(senderGroup);
|
||||
groups["recv"] = new JsonArray("public", senderGroup);
|
||||
|
||||
var backupPath = WriteWithBackup(root);
|
||||
return new ApplyResult(Success: true, ConfigPath: ConfigPath, BackupPath: backupPath, ErrorMessage: null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ApplyResult(Success: false, ConfigPath: ConfigPath, BackupPath: null, ErrorMessage: ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores defaults: senders on <c>public</c>, receivers on <c>public</c>.
|
||||
/// Equivalent to undoing <see cref="ApplyTranscoderTopology"/>.
|
||||
/// </summary>
|
||||
public static ApplyResult RestoreDefaults()
|
||||
{
|
||||
try
|
||||
{
|
||||
var root = LoadOrCreate();
|
||||
var ndi = EnsureObject(root, "ndi");
|
||||
var groups = EnsureObject(ndi, "groups");
|
||||
groups["send"] = new JsonArray("public");
|
||||
groups["recv"] = new JsonArray("public");
|
||||
var backupPath = WriteWithBackup(root);
|
||||
return new ApplyResult(Success: true, ConfigPath: ConfigPath, BackupPath: backupPath, ErrorMessage: null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ApplyResult(Success: false, ConfigPath: ConfigPath, BackupPath: null, ErrorMessage: ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the current sender / receiver group lists, or null if the config doesn't
|
||||
/// exist yet (NDI Access Manager has never been opened on this machine).
|
||||
/// </summary>
|
||||
public static (IReadOnlyList<string>? Send, IReadOnlyList<string>? Recv) ReadCurrentGroups()
|
||||
{
|
||||
if (!File.Exists(ConfigPath)) return (null, null);
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(ConfigPath);
|
||||
var root = JsonNode.Parse(stream);
|
||||
var groups = root?["ndi"]?["groups"];
|
||||
return (
|
||||
AsStringList(groups?["send"]),
|
||||
AsStringList(groups?["recv"]));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? AsStringList(JsonNode? node) =>
|
||||
node is JsonArray arr ? arr.Select(n => n?.GetValue<string>() ?? string.Empty).ToArray() : null;
|
||||
|
||||
private static JsonObject LoadOrCreate()
|
||||
{
|
||||
if (File.Exists(ConfigPath))
|
||||
{
|
||||
using var stream = File.OpenRead(ConfigPath);
|
||||
var existing = JsonNode.Parse(stream) as JsonObject;
|
||||
if (existing is not null) return existing;
|
||||
}
|
||||
return new JsonObject();
|
||||
}
|
||||
|
||||
private static JsonObject EnsureObject(JsonNode? parent, string key)
|
||||
{
|
||||
if (parent is not JsonObject obj)
|
||||
throw new InvalidOperationException($"Cannot ensure key '{key}' on a non-object parent.");
|
||||
if (obj[key] is JsonObject existing) return existing;
|
||||
var fresh = new JsonObject();
|
||||
obj[key] = fresh;
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the config atomically (temp file + replace) and saves a backup of the
|
||||
/// prior contents next to the original with a timestamp suffix. Returns the
|
||||
/// backup path if a prior file existed; null on first-write.
|
||||
/// </summary>
|
||||
private static string? WriteWithBackup(JsonNode root)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(ConfigPath);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
string? backupPath = null;
|
||||
if (File.Exists(ConfigPath))
|
||||
{
|
||||
var stamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss");
|
||||
backupPath = ConfigPath + $".bak-{stamp}";
|
||||
File.Copy(ConfigPath, backupPath, overwrite: true);
|
||||
}
|
||||
|
||||
var json = root.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
|
||||
var tempPath = ConfigPath + ".tmp";
|
||||
File.WriteAllText(tempPath, json);
|
||||
if (File.Exists(ConfigPath)) File.Replace(tempPath, ConfigPath, destinationBackupFileName: null);
|
||||
else File.Move(tempPath, ConfigPath);
|
||||
|
||||
return backupPath;
|
||||
}
|
||||
}
|
||||
|
|
@ -15,4 +15,10 @@
|
|||
<ProjectReference Include="..\TeamsISO.Engine.NdiInterop\TeamsISO.Engine.NdiInterop.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Wild Dragon brand assets — embedded as resources so the published binary is self-contained. -->
|
||||
<ItemGroup>
|
||||
<Resource Include="Assets\dragon-mark.png" />
|
||||
<Resource Include="Assets\wild-dragon-wordmark.png" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
using System.Windows;
|
||||
using TeamsISO.App.Services;
|
||||
using TeamsISO.Engine.Controller;
|
||||
using TeamsISO.Engine.Domain;
|
||||
|
||||
|
|
@ -32,6 +34,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
|
|||
_outputGroups = groups.OutputGroups ?? string.Empty;
|
||||
|
||||
ApplyCommand = new AsyncRelayCommand(ApplyAsync);
|
||||
ApplyTranscoderTopologyCommand = new AsyncRelayCommand(ApplyTranscoderTopologyAsync);
|
||||
}
|
||||
|
||||
public IEnumerable<TargetFramerate> AvailableFramerates => Enum.GetValues<TargetFramerate>();
|
||||
|
|
@ -59,6 +62,15 @@ public sealed class GlobalSettingsViewModel : ObservableObject
|
|||
|
||||
public AsyncRelayCommand ApplyCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// One-click "set up the transcoder topology" — writes ndi-config.v1.json so all
|
||||
/// local senders broadcast on a private group ("teamsiso-input") while local
|
||||
/// receivers can see both that and "public", then sets the engine's discovery and
|
||||
/// output groups to align (engine receives from the private group, emits on Public).
|
||||
/// User has to restart Teams for the new ndi-config.v1.json to take effect there.
|
||||
/// </summary>
|
||||
public AsyncRelayCommand ApplyTranscoderTopologyCommand { get; }
|
||||
|
||||
private async Task ApplyAsync()
|
||||
{
|
||||
var settings = new FrameProcessingSettings(_framerate, _resolution, _aspect, _audio);
|
||||
|
|
@ -69,4 +81,45 @@ public sealed class GlobalSettingsViewModel : ObservableObject
|
|||
OutputGroups: string.IsNullOrWhiteSpace(_outputGroups) ? null : _outputGroups.Trim());
|
||||
await _controller.SetGroupSettingsAsync(groups, CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task ApplyTranscoderTopologyAsync()
|
||||
{
|
||||
// 1. Update the machine-wide NDI config so Teams' raw broadcasts go to the
|
||||
// private group instead of polluting Public.
|
||||
var result = NdiAccessManagerConfig.ApplyTranscoderTopology();
|
||||
if (!result.Success)
|
||||
{
|
||||
MessageBox.Show(
|
||||
$"Could not write NDI Access Manager config.\n\n{result.ErrorMessage}\n\nPath: {result.ConfigPath}",
|
||||
"TeamsISO — Apply transcoder topology",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Update the engine: receive only from the private group, emit on Public.
|
||||
var ourGroups = new NdiGroupSettings(
|
||||
DiscoveryGroups: NdiAccessManagerConfig.TranscoderInputGroup,
|
||||
OutputGroups: "public");
|
||||
await _controller.SetGroupSettingsAsync(ourGroups, CancellationToken.None);
|
||||
|
||||
// 3. Reflect the new values in the bound text boxes.
|
||||
DiscoveryGroups = NdiAccessManagerConfig.TranscoderInputGroup;
|
||||
OutputGroups = "public";
|
||||
|
||||
var backupNote = result.BackupPath is null
|
||||
? "No prior NDI config existed; a fresh one was created."
|
||||
: $"A backup of your prior NDI config was saved to:\n{result.BackupPath}";
|
||||
|
||||
MessageBox.Show(
|
||||
"Transcoder topology applied. ✓\n\n" +
|
||||
"• Local senders (Teams, etc.) will broadcast on group 'teamsiso-input'.\n" +
|
||||
"• Local receivers will see both 'public' and 'teamsiso-input'.\n" +
|
||||
"• TeamsISO will discover from 'teamsiso-input' and re-emit on 'public'.\n\n" +
|
||||
"RESTART Microsoft Teams for the new NDI config to take effect there.\n\n" +
|
||||
backupNote,
|
||||
"TeamsISO — Apply transcoder topology",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Information);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue