Compare commits
5 commits
f11f2b947f
...
daa4068784
| Author | SHA1 | Date | |
|---|---|---|---|
| daa4068784 | |||
| e1b3339cc2 | |||
| f9f58a9ff4 | |||
| 6a141c1031 | |||
| 97d7028a3a |
12 changed files with 288 additions and 43 deletions
42
CHANGELOG.md
42
CHANGELOG.md
|
|
@ -4,6 +4,44 @@ All notable changes to Dragon-ISO are documented here. The format follows
|
||||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
|
||||||
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.2.0-beta.1] — 2026-07-06
|
||||||
|
|
||||||
|
Beta pre-release for the framerate and reliability work since 1.1.0.
|
||||||
|
|
||||||
|
### Engine
|
||||||
|
|
||||||
|
- **Exact rational framerate end-to-end** — the configured target framerate
|
||||||
|
now flows from settings to every NDI sender as an exact rational
|
||||||
|
(e.g. 59.94 = 60000/1001), and each outgoing video frame is stamped with
|
||||||
|
it. Previously every output advertised 59.94 regardless of configuration;
|
||||||
|
advertising the wrong rational causes drift and A/V desync downstream.
|
||||||
|
- **Audio FourCC fix** — corrected the PCMs16 FourCC constant; audio FourCCs
|
||||||
|
are now pinned by tests to their ASCII packing.
|
||||||
|
- **Finder rebuild hardening** — a failed NDI finder rebuild no longer leaves
|
||||||
|
a disposed finder behind (the self-heal path can retry cleanly).
|
||||||
|
- **Scaler zero-dimension guard** — a source frame with a zero dimension is
|
||||||
|
dropped instead of crashing the pipeline.
|
||||||
|
|
||||||
|
### Installer
|
||||||
|
|
||||||
|
- **MSI ProductVersion now tracks the build version** — it was hardcoded to
|
||||||
|
1.0.0.0, so in-place upgrades (MajorUpgrade) never saw a newer version.
|
||||||
|
Upgrading from an earlier install may require a manual uninstall once.
|
||||||
|
- **Control surface works out of the box for standard users** — the installer
|
||||||
|
now reserves the http.sys URL namespace for `http://127.0.0.1:9755/`
|
||||||
|
(removed again on uninstall). Previously the REST/WebSocket control surface
|
||||||
|
failed with "Access is denied" on every non-Administrator launch. Note for
|
||||||
|
LAN-reachable mode: it still needs its own `http://+:9755/` reservation
|
||||||
|
(the app logs the exact command), and that reservation must be deleted when
|
||||||
|
reverting to loopback-only — while idle it makes http.sys 503 every request.
|
||||||
|
- **CAB embedded in the MSI** — the installer is a single self-contained
|
||||||
|
file again; distributing only the MSI no longer fails with error 1302.
|
||||||
|
|
||||||
|
## [1.1.0] — 2026-05-31
|
||||||
|
|
||||||
|
Rebrand release: TeamsISO becomes **Dragon-ISO** across source paths,
|
||||||
|
installer metadata, and documentation. No functional engine changes.
|
||||||
|
|
||||||
## [1.0.0] — 2026-05-17
|
## [1.0.0] — 2026-05-17
|
||||||
|
|
||||||
First general release. Windows-only, .NET 8 WPF, NDI 6.
|
First general release. Windows-only, .NET 8 WPF, NDI 6.
|
||||||
|
|
@ -83,4 +121,6 @@ First general release. Windows-only, .NET 8 WPF, NDI 6.
|
||||||
- WiX MSI installer with proper Add/Remove Programs metadata, Start Menu
|
- WiX MSI installer with proper Add/Remove Programs metadata, Start Menu
|
||||||
+ Desktop shortcuts, and in-place upgrade.
|
+ Desktop shortcuts, and in-place upgrade.
|
||||||
|
|
||||||
[1.0.0]: https://forge.wilddragon.net/zgaetano/Dragon-ISO/releases/tag/v1.0.0
|
[1.2.0-beta.1]: https://forge.wilddragon.net/WildDragonLLC/dragon-iso/releases/tag/v1.2.0-beta.1
|
||||||
|
[1.1.0]: https://forge.wilddragon.net/WildDragonLLC/dragon-iso/releases/tag/v1.1.0
|
||||||
|
[1.0.0]: https://forge.wilddragon.net/WildDragonLLC/dragon-iso/releases/tag/v1.0.0
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,10 @@
|
||||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||||
<AnalysisLevel>latest</AnalysisLevel>
|
<AnalysisLevel>latest</AnalysisLevel>
|
||||||
<Version>1.0.0</Version>
|
<Version>1.2.0-beta.1</Version>
|
||||||
<Authors>Wild Dragon LLC</Authors>
|
<Authors>Wild Dragon LLC</Authors>
|
||||||
<Company>Wild Dragon LLC</Company>
|
<Company>Wild Dragon LLC</Company>
|
||||||
<Product>TeamsISO</Product>
|
<Product>Dragon-ISO</Product>
|
||||||
<Copyright>Copyright © Wild Dragon LLC 2026</Copyright>
|
<Copyright>Copyright © Wild Dragon LLC 2026</Copyright>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,17 @@
|
||||||
-->
|
-->
|
||||||
<PublishDir>$(MSBuildThisFileDirectory)..\publish\Dragon-ISO\</PublishDir>
|
<PublishDir>$(MSBuildThisFileDirectory)..\publish\Dragon-ISO\</PublishDir>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
MSI ProductVersion must be numeric x.y.z (no -beta/-rc suffix), and it is
|
||||||
|
what MajorUpgrade compares — so it must track the real build version, not
|
||||||
|
a hardcoded literal. Strip any prerelease suffix from $(Version). Note:
|
||||||
|
two prereleases of the same x.y.z share a ProductVersion and won't
|
||||||
|
upgrade over each other; bump the patch digit between betas.
|
||||||
|
-->
|
||||||
|
<MsiProductVersion>$([System.Text.RegularExpressions.Regex]::Match('$(Version)', '^\d+\.\d+(\.\d+)?').Value)</MsiProductVersion>
|
||||||
|
|
||||||
<!-- Pass MSBuild values into WiX preprocessor. -->
|
<!-- Pass MSBuild values into WiX preprocessor. -->
|
||||||
<DefineConstants>PublishDir=$(PublishDir);AssetsDir=$(MSBuildThisFileDirectory)..\src\Dragon-ISO.App\Assets\</DefineConstants>
|
<DefineConstants>PublishDir=$(PublishDir);AssetsDir=$(MSBuildThisFileDirectory)..\src\Dragon-ISO.App\Assets\;ProductVersion=$(MsiProductVersion)</DefineConstants>
|
||||||
|
|
||||||
<!-- Code-signing hook (set externally for release builds; left empty for dev). -->
|
<!-- Code-signing hook (set externally for release builds; left empty for dev). -->
|
||||||
<SignOutput Condition=" '$(SignOutput)' == '' ">false</SignOutput>
|
<SignOutput Condition=" '$(SignOutput)' == '' ">false</SignOutput>
|
||||||
|
|
@ -30,6 +39,8 @@
|
||||||
-->
|
-->
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="WixToolset.UI.wixext" Version="5.0.2" />
|
<PackageReference Include="WixToolset.UI.wixext" Version="5.0.2" />
|
||||||
|
<!-- WixQuietExec for the URL ACL custom actions (control surface, port 9755). -->
|
||||||
|
<PackageReference Include="WixToolset.Util.wixext" Version="5.0.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
|
|
||||||
<Package Name="Dragon-ISO"
|
<Package Name="Dragon-ISO"
|
||||||
Manufacturer="Wild Dragon LLC"
|
Manufacturer="Wild Dragon LLC"
|
||||||
Version="1.0.0.0"
|
Version="$(var.ProductVersion)"
|
||||||
UpgradeCode="9F4A8B2C-1D3E-4A5B-9C6D-8E7F0A1B2C3D"
|
UpgradeCode="9F4A8B2C-1D3E-4A5B-9C6D-8E7F0A1B2C3D"
|
||||||
Scope="perMachine"
|
Scope="perMachine"
|
||||||
Compressed="yes"
|
Compressed="yes"
|
||||||
|
|
@ -224,5 +224,62 @@
|
||||||
</Component>
|
</Component>
|
||||||
</ComponentGroup>
|
</ComponentGroup>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
URL ACL reservation for the control surface (REST/WebSocket, default
|
||||||
|
port 9755). The server uses HttpListener (http.sys), and http.sys
|
||||||
|
denies prefix registration to non-elevated processes unless the URL
|
||||||
|
namespace is reserved — without this, the control surface fails with
|
||||||
|
"Access is denied (5)" on every standard-user launch and the operator
|
||||||
|
has to run the app elevated or run netsh by hand. The MSI already runs
|
||||||
|
elevated, so reserve the namespace here.
|
||||||
|
|
||||||
|
IMPORTANT — reserve ONLY the explicit loopback prefix, never
|
||||||
|
http://+:9755/. Verified empirically 2026-07-06: an idle strong-
|
||||||
|
wildcard RESERVATION poisons http.sys routing for the app's IP-bound
|
||||||
|
registration — every request 503s inside http.sys without ever
|
||||||
|
reaching the listener. The LAN-reachable mode (which binds
|
||||||
|
http://+:9755/) remains an opt-in that needs its own reservation;
|
||||||
|
the app logs the exact netsh command when that bind fails, and the
|
||||||
|
operator must delete that + reservation again if they revert to
|
||||||
|
loopback-only.
|
||||||
|
|
||||||
|
SDDL D:(A;;GX;;;WD) = allow GENERIC_EXECUTE (listen) to Everyone by
|
||||||
|
SID, which survives non-English locales where "Everyone" isn't a
|
||||||
|
valid account name. Operators who change the port in settings still
|
||||||
|
need the app's documented netsh one-liner for the new port.
|
||||||
|
Return="ignore": an already-existing reservation (repair/reinstall)
|
||||||
|
exits non-zero and must not fail the install.
|
||||||
|
-->
|
||||||
|
<SetProperty Id="AddControlSurfaceUrlAcl"
|
||||||
|
Value=""[System64Folder]netsh.exe" http add urlacl url=http://127.0.0.1:9755/ sddl=D:(A;;GX;;;WD)"
|
||||||
|
Sequence="execute"
|
||||||
|
Before="AddControlSurfaceUrlAcl" />
|
||||||
|
<CustomAction Id="AddControlSurfaceUrlAcl"
|
||||||
|
BinaryRef="Wix4UtilCA_X64"
|
||||||
|
DllEntry="WixQuietExec64"
|
||||||
|
Execute="deferred"
|
||||||
|
Impersonate="no"
|
||||||
|
Return="ignore" />
|
||||||
|
|
||||||
|
<SetProperty Id="RemoveControlSurfaceUrlAcl"
|
||||||
|
Value=""[System64Folder]netsh.exe" http delete urlacl url=http://127.0.0.1:9755/"
|
||||||
|
Sequence="execute"
|
||||||
|
Before="RemoveControlSurfaceUrlAcl" />
|
||||||
|
<CustomAction Id="RemoveControlSurfaceUrlAcl"
|
||||||
|
BinaryRef="Wix4UtilCA_X64"
|
||||||
|
DllEntry="WixQuietExec64"
|
||||||
|
Execute="deferred"
|
||||||
|
Impersonate="no"
|
||||||
|
Return="ignore" />
|
||||||
|
|
||||||
|
<InstallExecuteSequence>
|
||||||
|
<Custom Action="AddControlSurfaceUrlAcl"
|
||||||
|
After="InstallFiles"
|
||||||
|
Condition="NOT Installed OR REINSTALL" />
|
||||||
|
<Custom Action="RemoveControlSurfaceUrlAcl"
|
||||||
|
After="InstallInitialize"
|
||||||
|
Condition="REMOVE="ALL"" />
|
||||||
|
</InstallExecuteSequence>
|
||||||
|
|
||||||
</Package>
|
</Package>
|
||||||
</Wix>
|
</Wix>
|
||||||
|
|
@ -97,11 +97,17 @@ public sealed partial class ControlSurfaceServer : IAsyncDisposable
|
||||||
/// "headless show machine + thin client controller" setups. When false
|
/// "headless show machine + thin client controller" setups. When false
|
||||||
/// (default), binds to <c>127.0.0.1</c> only.
|
/// (default), binds to <c>127.0.0.1</c> only.
|
||||||
///
|
///
|
||||||
/// LAN binding requires either running Dragon-ISO as Administrator OR a
|
/// Binding requires either running Dragon-ISO as Administrator OR a
|
||||||
/// one-time URL ACL reservation at the OS level:
|
/// one-time URL ACL reservation at the OS level for the prefix actually
|
||||||
/// <code>netsh http add urlacl url=http://+:9755/ user=Everyone</code>
|
/// being bound (the installer reserves the loopback default,
|
||||||
/// If neither is in place the listener throws AccessDeniedException
|
/// <c>http://127.0.0.1:9755/</c>):
|
||||||
/// which we catch and surface as a logger warning.
|
/// <code>netsh http add urlacl url=http://127.0.0.1:9755/ sddl=D:(A;;GX;;;WD)</code>
|
||||||
|
/// or <c>url=http://+:9755/</c> for LAN mode. CAUTION: an idle
|
||||||
|
/// <c>http://+:port/</c> reservation makes http.sys 503 every request to
|
||||||
|
/// the loopback-bound listener — only add it while LAN mode is in use,
|
||||||
|
/// and delete it when reverting to loopback-only. If no reservation is
|
||||||
|
/// in place the listener throws AccessDeniedException which we catch and
|
||||||
|
/// surface as a logger warning.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Start(int port, bool bindToLan = false)
|
public void Start(int port, bool bindToLan = false)
|
||||||
{
|
{
|
||||||
|
|
@ -123,9 +129,11 @@ public sealed partial class ControlSurfaceServer : IAsyncDisposable
|
||||||
{
|
{
|
||||||
_logger?.LogWarning(ex,
|
_logger?.LogWarning(ex,
|
||||||
"Could not start control surface on {Prefix}. " +
|
"Could not start control surface on {Prefix}. " +
|
||||||
"If binding to LAN, run as Administrator once OR run: " +
|
"Run as Administrator once OR reserve the URL namespace: " +
|
||||||
"netsh http add urlacl url=http://+:{Port}/ user=Everyone",
|
"netsh http add urlacl url={UrlAclUrl} sddl=D:(A;;GX;;;WD) " +
|
||||||
prefix, port);
|
"(if switching back from LAN mode, also delete the stale " +
|
||||||
|
"http://+:{Port}/ reservation — while idle it 503s the loopback listener)",
|
||||||
|
prefix, prefix, port);
|
||||||
_listener = null;
|
_listener = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -275,7 +275,7 @@ public sealed class NdiInteropPInvoke : INdiInterop, IDisposable
|
||||||
|
|
||||||
// ---- Send ----
|
// ---- Send ----
|
||||||
|
|
||||||
public NdiSenderHandle CreateSender(string outputName, string? groups = null)
|
public NdiSenderHandle CreateSender(string outputName, string? groups = null, int frameRateN = 60000, int frameRateD = 1001)
|
||||||
{
|
{
|
||||||
var trimmedGroups = NormalizeGroups(groups);
|
var trimmedGroups = NormalizeGroups(groups);
|
||||||
var nameUtf8 = Marshal.StringToHGlobalAnsi(outputName);
|
var nameUtf8 = Marshal.StringToHGlobalAnsi(outputName);
|
||||||
|
|
@ -297,7 +297,7 @@ public sealed class NdiInteropPInvoke : INdiInterop, IDisposable
|
||||||
$"NDIlib_send_create returned null for output '{outputName}' on groups '{trimmedGroups ?? "<default>"}'.");
|
$"NDIlib_send_create returned null for output '{outputName}' on groups '{trimmedGroups ?? "<default>"}'.");
|
||||||
if (trimmedGroups is not null)
|
if (trimmedGroups is not null)
|
||||||
_logger.LogInformation("NDI sender '{Output}' created on groups: {Groups}", outputName, trimmedGroups);
|
_logger.LogInformation("NDI sender '{Output}' created on groups: {Groups}", outputName, trimmedGroups);
|
||||||
return new NdiPInvokeSenderHandle(native, outputName);
|
return new NdiPInvokeSenderHandle(native, outputName, frameRateN, frameRateD);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|
@ -310,6 +310,29 @@ public sealed class NdiInteropPInvoke : INdiInterop, IDisposable
|
||||||
{
|
{
|
||||||
var pInvokeSender = (NdiPInvokeSenderHandle)sender;
|
var pInvokeSender = (NdiPInvokeSenderHandle)sender;
|
||||||
|
|
||||||
|
// Pin the managed buffer so the native call can read it directly.
|
||||||
|
var pixels = frame.Pixels.ToArray();
|
||||||
|
var handle = GCHandle.Alloc(pixels, GCHandleType.Pinned);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var nativeFrame = BuildVideoFrame(
|
||||||
|
frame, handle.AddrOfPinnedObject(),
|
||||||
|
pInvokeSender.FrameRateN, pInvokeSender.FrameRateD);
|
||||||
|
NdiNative.SendSendVideoV2(pInvokeSender.Native, ref nativeFrame);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
handle.Free();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure assembly of the native frame descriptor — extracted so the stamping
|
||||||
|
/// (frame rate, stride, dimensions) is unit-testable without an NDI runtime.
|
||||||
|
/// </summary>
|
||||||
|
internal static NdiNative.VideoFrameV2 BuildVideoFrame(
|
||||||
|
ProcessedFrame frame, IntPtr data, int frameRateN, int frameRateD)
|
||||||
|
{
|
||||||
var fourcc = frame.Format switch
|
var fourcc = frame.Format switch
|
||||||
{
|
{
|
||||||
PixelFormat.Bgra => NdiNative.FourCC.BGRA,
|
PixelFormat.Bgra => NdiNative.FourCC.BGRA,
|
||||||
|
|
@ -318,32 +341,21 @@ public sealed class NdiInteropPInvoke : INdiInterop, IDisposable
|
||||||
_ => NdiNative.FourCC.BGRA
|
_ => NdiNative.FourCC.BGRA
|
||||||
};
|
};
|
||||||
|
|
||||||
// Pin the managed buffer so the native call can read it directly.
|
return new NdiNative.VideoFrameV2
|
||||||
var pixels = frame.Pixels.ToArray();
|
|
||||||
var handle = GCHandle.Alloc(pixels, GCHandleType.Pinned);
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
var nativeFrame = new NdiNative.VideoFrameV2
|
xres = frame.Width,
|
||||||
{
|
yres = frame.Height,
|
||||||
xres = frame.Width,
|
FourCC = fourcc,
|
||||||
yres = frame.Height,
|
frame_rate_N = frameRateN,
|
||||||
FourCC = fourcc,
|
frame_rate_D = frameRateD,
|
||||||
frame_rate_N = 60000,
|
picture_aspect_ratio = 0.0f, // 0 = derive from xres/yres
|
||||||
frame_rate_D = 1001, // 59.94 default; controller-supplied later
|
frame_format_type = NdiNative.FrameFormatType.Progressive,
|
||||||
picture_aspect_ratio = 0.0f, // 0 = derive from xres/yres
|
timecode = NdiTimecodeSynthesize, // synthesize
|
||||||
frame_format_type = NdiNative.FrameFormatType.Progressive,
|
p_data = data,
|
||||||
timecode = NdiTimecodeSynthesize, // synthesize
|
line_stride_in_bytes = frame.Width * BytesPerPixel(frame.Format),
|
||||||
p_data = handle.AddrOfPinnedObject(),
|
p_metadata = IntPtr.Zero,
|
||||||
line_stride_in_bytes = frame.Width * BytesPerPixel(frame.Format),
|
timestamp = frame.TimestampTicks
|
||||||
p_metadata = IntPtr.Zero,
|
};
|
||||||
timestamp = frame.TimestampTicks
|
|
||||||
};
|
|
||||||
NdiNative.SendSendVideoV2(pInvokeSender.Native, ref nativeFrame);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
handle.Free();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private const long NdiTimecodeSynthesize = unchecked((long)0x8000000000000000UL); // NDIlib_send_timecode_synthesize
|
private const long NdiTimecodeSynthesize = unchecked((long)0x8000000000000000UL); // NDIlib_send_timecode_synthesize
|
||||||
|
|
|
||||||
|
|
@ -44,10 +44,18 @@ internal sealed class NdiPInvokeSenderHandle : NdiSenderHandle
|
||||||
public IntPtr Native { get; private set; }
|
public IntPtr Native { get; private set; }
|
||||||
public string OutputName { get; }
|
public string OutputName { get; }
|
||||||
|
|
||||||
public NdiPInvokeSenderHandle(IntPtr native, string outputName)
|
// The NDI SDK carries frame rate per-frame (VideoFrameV2.frame_rate_N/D),
|
||||||
|
// not on send_create, so the handle remembers the configured rational and
|
||||||
|
// SendFrame stamps it onto every outgoing frame.
|
||||||
|
public int FrameRateN { get; }
|
||||||
|
public int FrameRateD { get; }
|
||||||
|
|
||||||
|
public NdiPInvokeSenderHandle(IntPtr native, string outputName, int frameRateN = 60000, int frameRateD = 1001)
|
||||||
{
|
{
|
||||||
Native = native;
|
Native = native;
|
||||||
OutputName = outputName;
|
OutputName = outputName;
|
||||||
|
FrameRateN = frameRateN;
|
||||||
|
FrameRateD = frameRateD;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void Dispose()
|
public override void Dispose()
|
||||||
|
|
|
||||||
|
|
@ -378,9 +378,10 @@ public sealed class IsoPipeline : IAsyncDisposable
|
||||||
|
|
||||||
using var receiver = new NdiReceiver(
|
using var receiver = new NdiReceiver(
|
||||||
interop, config.SourceName, rawWriter, loggerFactory.CreateLogger<NdiReceiver>());
|
interop, config.SourceName, rawWriter, loggerFactory.CreateLogger<NdiReceiver>());
|
||||||
|
var (frameRateN, frameRateD) = config.Settings.FrameRate;
|
||||||
using var sender = new NdiSender(
|
using var sender = new NdiSender(
|
||||||
interop, config.OutputName, processedChannel.Reader, loggerFactory.CreateLogger<NdiSender>(),
|
interop, config.OutputName, processedChannel.Reader, loggerFactory.CreateLogger<NdiSender>(),
|
||||||
config.OutputGroups);
|
config.OutputGroups, frameRateN, frameRateD);
|
||||||
|
|
||||||
var processor = new FrameProcessor(
|
var processor = new FrameProcessor(
|
||||||
config.Settings, scaler, new SolidFrameRenderer(),
|
config.Settings, scaler, new SolidFrameRenderer(),
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ public sealed class FakeNdiInterop : INdiInterop
|
||||||
public string? LastFinderGroups { get; private set; }
|
public string? LastFinderGroups { get; private set; }
|
||||||
/// <summary>Per-output <c>groups</c> string seen by <see cref="CreateSender"/>; null = default Public.</summary>
|
/// <summary>Per-output <c>groups</c> string seen by <see cref="CreateSender"/>; null = default Public.</summary>
|
||||||
public Dictionary<string, string?> SenderGroups { get; } = new();
|
public Dictionary<string, string?> SenderGroups { get; } = new();
|
||||||
|
/// <summary>Per-output rational frame rate (N, D) seen by <see cref="CreateSender"/>.</summary>
|
||||||
|
public Dictionary<string, (int N, int D)> SenderFrameRates { get; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Number of finders created so far (initial construction + every rebuild).
|
/// Number of finders created so far (initial construction + every rebuild).
|
||||||
|
|
@ -77,10 +79,11 @@ public sealed class FakeNdiInterop : INdiInterop
|
||||||
return null; // no audio queued — simulate timeout
|
return null; // no audio queued — simulate timeout
|
||||||
}
|
}
|
||||||
|
|
||||||
public NdiSenderHandle CreateSender(string outputName, string? groups = null)
|
public NdiSenderHandle CreateSender(string outputName, string? groups = null, int frameRateN = 60000, int frameRateD = 1001)
|
||||||
{
|
{
|
||||||
SenderCreatedCount[outputName] = SenderCreatedCount.GetValueOrDefault(outputName) + 1;
|
SenderCreatedCount[outputName] = SenderCreatedCount.GetValueOrDefault(outputName) + 1;
|
||||||
SenderGroups[outputName] = groups;
|
SenderGroups[outputName] = groups;
|
||||||
|
SenderFrameRates[outputName] = (frameRateN, frameRateD);
|
||||||
SentFrames.GetOrAdd(outputName, _ => new List<ProcessedFrame>());
|
SentFrames.GetOrAdd(outputName, _ => new List<ProcessedFrame>());
|
||||||
return new FakeSenderHandle(outputName);
|
return new FakeSenderHandle(outputName);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
using DragonISO.Engine.NdiInterop;
|
||||||
|
using DragonISO.Engine.Pipeline;
|
||||||
|
|
||||||
|
namespace DragonISO.Engine.Tests.Interop;
|
||||||
|
|
||||||
|
// NdiInteropPInvoke is [SupportedOSPlatform("windows")]; these tests exercise
|
||||||
|
// pure helpers that never touch native code, but inherit the platform tag.
|
||||||
|
// Same pattern as NdiInteropNormalizeGroupsTests.
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
|
||||||
|
// NdiPInvokeSenderHandle and NdiInteropPInvoke.BuildVideoFrame are internal;
|
||||||
|
// the engine tests project has access via InternalsVisibleTo.
|
||||||
|
public class NdiInteropSendFrameTests
|
||||||
|
{
|
||||||
|
private static ProcessedFrame MakeFrame(int width, int height, PixelFormat format) =>
|
||||||
|
new(width, height, 42, new byte[width * height * 4], format);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SenderHandle_CarriesFrameRate()
|
||||||
|
{
|
||||||
|
// CreateSender stashes the configured rational on the handle so SendFrame
|
||||||
|
// can stamp every outgoing video frame without re-plumbing the settings.
|
||||||
|
var handle = new NdiPInvokeSenderHandle(IntPtr.Zero, "Dragon-ISO_01", 30000, 1001);
|
||||||
|
|
||||||
|
handle.FrameRateN.Should().Be(30000);
|
||||||
|
handle.FrameRateD.Should().Be(1001);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildVideoFrame_StampsConfiguredFrameRate()
|
||||||
|
{
|
||||||
|
var frame = MakeFrame(1280, 720, PixelFormat.Bgra);
|
||||||
|
|
||||||
|
var native = NdiInteropPInvoke.BuildVideoFrame(frame, IntPtr.Zero, 24000, 1001);
|
||||||
|
|
||||||
|
native.frame_rate_N.Should().Be(24000,
|
||||||
|
because: "advertising the wrong rational causes drift and A/V desync downstream");
|
||||||
|
native.frame_rate_D.Should().Be(1001);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildVideoFrame_PreservesDimensionsAndStride()
|
||||||
|
{
|
||||||
|
var frame = MakeFrame(1920, 1080, PixelFormat.Bgra);
|
||||||
|
|
||||||
|
var native = NdiInteropPInvoke.BuildVideoFrame(frame, IntPtr.Zero, 60000, 1001);
|
||||||
|
|
||||||
|
native.xres.Should().Be(1920);
|
||||||
|
native.yres.Should().Be(1080);
|
||||||
|
native.line_stride_in_bytes.Should().Be(1920 * 4, because: "BGRA is 4 bytes per pixel");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildVideoFrame_MapsUyvyStride()
|
||||||
|
{
|
||||||
|
var frame = MakeFrame(1280, 720, PixelFormat.Uyvy);
|
||||||
|
|
||||||
|
var native = NdiInteropPInvoke.BuildVideoFrame(frame, IntPtr.Zero, 60000, 1001);
|
||||||
|
|
||||||
|
native.line_stride_in_bytes.Should().Be(1280 * 2, because: "UYVY is 2 bytes per pixel");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using DragonISO.Engine.Domain;
|
using DragonISO.Engine.Domain;
|
||||||
using DragonISO.Engine.Pipeline;
|
using DragonISO.Engine.Pipeline;
|
||||||
|
using DragonISO.Engine.Tests.Fakes;
|
||||||
|
|
||||||
namespace DragonISO.Engine.Tests.Pipeline;
|
namespace DragonISO.Engine.Tests.Pipeline;
|
||||||
|
|
||||||
|
|
@ -76,6 +77,32 @@ public class IsoPipelineTests
|
||||||
await pipeline.StopAsync();
|
await pipeline.StopAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProductionCtor_PassesConfiguredFrameRate_ToSender()
|
||||||
|
{
|
||||||
|
// The operator configures 29.97 in settings; the NDI sender for every ISO
|
||||||
|
// output must be created with that exact rational (30000/1001), not the
|
||||||
|
// hardwired 59.94 default.
|
||||||
|
var interop = new FakeNdiInterop();
|
||||||
|
var config = new IsoPipelineConfig(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"Teams - Alice",
|
||||||
|
"Alice-ISO",
|
||||||
|
FrameProcessingSettings.Default with { Framerate = TargetFramerate.Fps29_97 });
|
||||||
|
var pipeline = new IsoPipeline(
|
||||||
|
config, interop, new ManagedNearestNeighborFrameScaler(), new FakeFrameClock(),
|
||||||
|
FastBackoff(), NoDelay(), NullLoggerFactory.Instance);
|
||||||
|
|
||||||
|
await pipeline.StartAsync();
|
||||||
|
var deadline = DateTime.UtcNow.AddSeconds(2);
|
||||||
|
while (!interop.SenderFrameRates.ContainsKey("Alice-ISO") && DateTime.UtcNow < deadline)
|
||||||
|
await Task.Delay(10);
|
||||||
|
await pipeline.StopAsync();
|
||||||
|
|
||||||
|
interop.SenderFrameRates.Should().ContainKey("Alice-ISO");
|
||||||
|
interop.SenderFrameRates["Alice-ISO"].Should().Be((30000, 1001));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Supervisor_FailsRepeatedly_TransitionsToError()
|
public async Task Supervisor_FailsRepeatedly_TransitionsToError()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,21 @@ public class NdiSenderTests
|
||||||
private static ProcessedFrame MakeFrame(long ts) =>
|
private static ProcessedFrame MakeFrame(long ts) =>
|
||||||
new(1920, 1080, ts, new byte[1920 * 1080 * 4], PixelFormat.Bgra);
|
new(1920, 1080, ts, new byte[1920 * 1080 * 4], PixelFormat.Bgra);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ctor_ForwardsFrameRate_ToCreateSender()
|
||||||
|
{
|
||||||
|
var interop = new FakeNdiInterop();
|
||||||
|
var input = Channel.CreateUnbounded<ProcessedFrame>();
|
||||||
|
|
||||||
|
using var sender = new NdiSender(
|
||||||
|
interop, Output, input.Reader, NullLogger<NdiSender>.Instance,
|
||||||
|
outputGroups: null, frameRateN: 30000, frameRateD: 1001);
|
||||||
|
|
||||||
|
interop.SenderFrameRates[Output].Should().Be((30000, 1001),
|
||||||
|
because: "the sender must hand the configured rational frame rate to the interop " +
|
||||||
|
"so the NDI stream advertises 29.97 rather than the 59.94 default");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SendNextAsync_FrameAvailable_ForwardsToInterop()
|
public async Task SendNextAsync_FrameAvailable_ForwardsToInterop()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue